Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/console/pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
packages:
- .

allowBuilds:
esbuild@0.25.12: true
95 changes: 95 additions & 0 deletions apps/console/src/browse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
type ToolResult = {
structuredContent?: unknown;
isError?: boolean;
};

type ReadTransport = {
call(tool: string, arguments_: Record<string, unknown>): Promise<ToolResult>;
};

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<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new BrowseError("ConsoleBrowseResponseMalformed");
return value as Record<string, unknown>;
}

function bounds(request: BrowseRequest): Record<string, unknown> {
return request.asOf ? { as_of: request.asOf } : {};
}

export function createBrowse(transport: ReadTransport) {
async function read(tool: string, arguments_: Record<string, unknown>): Promise<Record<string, unknown>> {
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) : []) };
},
};
}
7 changes: 5 additions & 2 deletions apps/console/src/entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<ConsoleAdapters, "turn" | "control"> & { read?: ConsoleAdapters["read"] }>;
type AdapterFactory = (input: { stores: ConsoleAdapters["stores"]; env: NodeJS.ProcessEnv }) => Promise<Pick<ConsoleAdapters, "turn" | "control"> & { read?: ConsoleAdapters["read"]; browse?: ConsoleAdapters["browse"] }>;

function required(name: string): string {
const value = process.env[name];
Expand Down Expand Up @@ -82,9 +83,10 @@ async function main(): Promise<void> {
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<ConsoleAdapters, "turn" | "control"> & { read?: ConsoleAdapters["read"] } = {
let adapters: Pick<ConsoleAdapters, "turn" | "control"> & { 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);
Expand All @@ -99,6 +101,7 @@ async function main(): Promise<void> {
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<void>((resolveListen, rejectListen) => {
Expand Down
40 changes: 36 additions & 4 deletions apps/console/src/index.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -35,6 +37,10 @@ export type ConsoleAdapters = {
adminCapability?: string;
turn: (input: TurnInput) => Promise<TurnResult>;
read: { list: (operator: Operator) => Promise<Store[]> };
browse?: {
discover: (input: { operator: Operator; store: string; asOf?: string }) => Promise<unknown>;
preview: (input: { operator: Operator; store: string; asOf?: string; path: string }) => Promise<unknown>;
};
control: {
workflows: (operator: Operator, store: string | null) => Promise<unknown>;
record: (operator: Operator, store: string | null) => Promise<unknown>;
Expand Down Expand Up @@ -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); }
Expand Down Expand Up @@ -303,11 +331,15 @@ const sharedStyle = `<style>
:root{font-family:ui-sans-serif,system-ui,sans-serif;color:#182b39;background:#e7edf0}*{box-sizing:border-box}body{margin:0;min-height:100vh}.shell{display:grid;grid-template-columns:210px minmax(0,1fr);min-height:100vh}nav{background:#173b50;color:#eaf4f5;padding:26px 20px}nav strong{font-size:1.3rem;letter-spacing:-.04em}nav a{display:block;color:#eaf4f5;text-decoration:none;margin-top:24px;padding:9px 11px;border-radius:7px}nav a[aria-current]{background:#31657b}main{padding:30px min(5vw,64px);max-width:1200px;width:100%}h1{font-size:clamp(2rem,4vw,3rem);letter-spacing:-.055em;margin:5px 0 12px}h2{font-size:1.1rem}p{line-height:1.5}section{background:#fff;border:1px solid #c8d5da;border-radius:12px;padding:20px;margin:18px 0}button,select,textarea{font:inherit}button{background:#125a72;color:white;border:0;border-radius:7px;padding:10px 16px;cursor:pointer}button:focus-visible,a:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #efae4c;outline-offset:2px}textarea{width:100%;min-height:100px;padding:12px;border:1px solid #91a8b2;border-radius:7px}select{padding:8px;border:1px solid #91a8b2;border-radius:7px}pre{white-space:pre-wrap;overflow-wrap:anywhere}.muted{color:#536b78}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}.node{border:1px solid #afc5cf;border-left:5px solid #3c7c92;border-radius:7px;padding:13px;background:#f5f9fa}#transcript article{border-left:3px solid #3c7c92;padding:8px 16px;margin:12px 0}#widgets table{border-collapse:collapse;width:100%}#widgets td,#widgets th{border-bottom:1px solid #c8d5da;padding:8px;text-align:left}@media(max-width:650px){.shell{display:block}nav{display:flex;gap:12px;align-items:center;padding:12px 20px}nav a{margin:0}main{padding:20px}}@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
</style>`;

const queryPage = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Query · Contextful</title>${sharedStyle}<div class="shell"><nav aria-label="Console"><strong>Contextful</strong><a href="/query" aria-current="page">Query</a><a href="/admin">Admin</a></nav><main><h1>Ask the store</h1><p class="muted">Answers cite the rows your access permits.</p><section><form id="composer"><label for="store">Store</label> <select id="store" required></select><p><label for="question">Question</label></p><textarea id="question" required></textarea><p><button type="submit">Ask question</button></p></form></section><section><h2>Conversation</h2><div id="transcript" role="log" aria-live="polite"><p class="muted">Your answer appears here.</p></div></section><section><h2>Results</h2><div id="widgets"></div></section></main></div><script>
const form=document.getElementById('composer'),store=document.getElementById('store'),transcript=document.getElementById('transcript'),widgets=document.getElementById('widgets');
fetch('/query/api/stores').then(r=>r.json()).then(rows=>{for(const row of rows){const option=document.createElement('option');option.value=row.id;option.textContent=row.label;store.append(option)}});
const queryPage = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Query · Contextful</title>${sharedStyle}<div class="shell"><nav aria-label="Console"><strong>Contextful</strong><a href="/query" aria-current="page">Query</a><a href="/admin">Admin</a></nav><main><h1>Ask the store</h1><p class="muted">Answers cite the rows your access permits.</p><section><label for="store">Store</label> <select id="store" required></select> <label for="as-of">As of</label> <input id="as-of" type="date"><p id="browse-status" role="status"></p><h2>Explore</h2><div id="chips" class="grid"></div><div id="insights" class="grid"></div><h2>Files</h2><div id="file-gallery" class="grid"></div><div id="file-preview"></div></section><section><form id="composer"><p><label for="question">Question</label></p><textarea id="question" required></textarea><p><button type="submit">Ask question</button></p></form></section><section><h2>Conversation</h2><div id="transcript" role="log" aria-live="polite"><p class="muted">Your answer appears here.</p></div></section><section><h2>Results</h2><div id="widgets"></div></section></main></div><script>
const form=document.getElementById('composer'),store=document.getElementById('store'),asOf=document.getElementById('as-of'),questionField=document.getElementById('question'),transcript=document.getElementById('transcript'),widgets=document.getElementById('widgets'),chips=document.getElementById('chips'),insights=document.getElementById('insights'),gallery=document.getElementById('file-gallery'),filePreview=document.getElementById('file-preview'),browseStatus=document.getElementById('browse-status');
async function post(path,payload){const response=await fetch(path,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)});const value=await response.json();if(!response.ok)throw new Error(value.error?.identifier??'Request failed');return value}
function current(){return {store:store.value,...(asOf.value?{asOf:asOf.value}:{})}}
function draw(widget){const props=widget.props??{};if(widget.component==='table.v1'&&Array.isArray(props.columns)&&Array.isArray(props.rows)){const table=document.createElement('table'),head=document.createElement('thead'),header=document.createElement('tr'),body=document.createElement('tbody');for(const column of props.columns){const cell=document.createElement('th');cell.textContent=String(column);header.append(cell)}head.append(header);for(const row of props.rows){const line=document.createElement('tr');for(const value of row){const cell=document.createElement('td');cell.textContent=String(value??'');line.append(cell)}body.append(line)}table.append(head,body);return table}if(widget.component==='metric.v1'){const metric=document.createElement('p');metric.style.fontSize='2rem';metric.textContent=String(props.value??'');return metric}const fallback=document.createElement('pre');fallback.textContent=JSON.stringify(props,null,2);return fallback}
form.addEventListener('submit',async event=>{event.preventDefault();const question=document.getElementById('question').value.trim();if(!question)return;const response=await fetch('/query/api/ask',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({store:store.value,question})});const answer=await response.json();transcript.replaceChildren();const item=document.createElement('article');item.textContent=question+'\n'+(answer.answer??answer.error?.identifier??'No answer');transcript.append(item);widgets.replaceChildren();for(const widget of answer.widgets??[])widgets.append(draw(widget));if(answer.sources?.length){const sources=document.createElement('p');sources.textContent='Sources: '+answer.sources.map(x=>x.title??x.url).join(', ');transcript.append(sources)}});
async function refreshBrowse(){chips.replaceChildren();insights.replaceChildren();gallery.replaceChildren();filePreview.replaceChildren();browseStatus.textContent='';if(!store.value)return;try{const data=await post('/query/api/browse',current());for(const chip of data.chips??[]){const button=document.createElement('button');button.type='button';button.textContent=chip.label;button.title=chip.description??'';button.addEventListener('click',()=>{questionField.value='Tell me about '+chip.label;questionField.focus()});chips.append(button)}for(const insight of data.insights??[]){const card=document.createElement('p');card.textContent=insight.label+': '+insight.rows+' rows';insights.append(card)}for(const file of data.files??[]){const button=document.createElement('button');button.type='button';button.textContent=file.label;button.addEventListener('click',async()=>{filePreview.replaceChildren();try{const preview=await post('/query/api/preview',{...current(),path:file.path});filePreview.append(draw({component:'table.v1',props:preview}))}catch(error){browseStatus.textContent=String(error)}});gallery.append(button)}}catch(error){browseStatus.textContent=String(error)}}
fetch('/query/api/stores').then(r=>r.json()).then(rows=>{for(const row of rows){const option=document.createElement('option');option.value=row.id;option.textContent=row.label;store.append(option)}void refreshBrowse()});
store.addEventListener('change',()=>{void refreshBrowse()});asOf.addEventListener('change',()=>{void refreshBrowse()});
form.addEventListener('submit',async event=>{event.preventDefault();const question=questionField.value.trim();if(!question)return;const response=await fetch('/query/api/ask',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({store:store.value,question})});const answer=await response.json();transcript.replaceChildren();const item=document.createElement('article');item.textContent=question+'\\n'+(answer.answer??answer.error?.identifier??'No answer');transcript.append(item);widgets.replaceChildren();for(const widget of answer.widgets??[])widgets.append(draw(widget));if(answer.sources?.length){const sources=document.createElement('p');sources.textContent='Sources: '+answer.sources.map(x=>x.title??x.url).join(', ');transcript.append(sources)}});
</script></html>`;

const adminPage = `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Admin · Contextful</title>${sharedStyle}<div class="shell"><nav aria-label="Console"><strong>Contextful</strong><a href="/query">Query</a><a href="/admin" aria-current="page">Admin</a></nav><main><h1>Store operations</h1><p class="muted">Pipelines, schedules, steps and run outcomes come from the store.</p><section><h2>Workflow canvas</h2><div id="canvas" class="grid"></div></section><section><h2>Operational record</h2><div id="record"></div></section><section><h2>Control document</h2><label for="document">Document</label><textarea id="document"></textarea><p><button id="edit">Edit</button> <button id="apply">Apply</button></p><p id="result" role="status"></p></section></main></div><script>
Expand Down
Loading