import { Hono } from "hono"; import { readFileSync } from "fs"; import { basename } from "path"; import open from "open"; import { z } from "zod"; import type { OntologyConfig, ResolverContext, EnvironmentConfig } from "../config/types.js"; import type { OntologyDiff } from "../lockfile/types.js"; import { writeLockfile } from "../lockfile/index.js"; import { serve, findAvailablePort } from "../runtime/index.js"; import { transformToGraphData, enhanceWithDiff, searchNodes, getNodeDetails, type EnhancedGraphData } from "./transform.js"; import { getFieldFromMetadata, hasUserContextMetadata, hasOrganizationContextMetadata } from "../config/categorical.js"; import { isZodObject, isZodOptional, isZodNullable, isZodDefault, isZodArray, getObjectShape, getInnerSchema, getArrayElement } from "../config/zod-utils.js"; import { browserAppHtml } from "./browser-app.js"; export interface BrowserServerOptions { config: OntologyConfig; /** Diff data for review mode (null = browse-only, no changes) */ diff?: OntologyDiff | null; /** Directory to write the lockfile to on approval */ configDir?: string; /** Path to the ontology.config.ts file */ configPath?: string; port?: number; openBrowser?: boolean; /** If true, resolve immediately after starting (don't wait for approval). Useful for background review UI. */ background?: boolean; } export interface BrowserServerResult { /** Whether changes were approved (only set if there were changes) */ approved?: boolean; } /** Field info for form generation */ interface FieldInfo { name: string; type: string; required: boolean; isUserContext: boolean; isOrganizationContext: boolean; fieldFrom?: string; isQueryBased?: boolean; schema: Record; innerSchema?: Record; } /** Analyze a Zod input schema for form generation */ function analyzeInputSchema(schema: z.ZodTypeAny, functions?: Record): FieldInfo[] { const fields: FieldInfo[] = []; if (!isZodObject(schema)) { return fields; } const shape = getObjectShape(schema); if (!shape) { return fields; } for (const [name, fieldSchema] of Object.entries(shape)) { const field = analyzeField(name, fieldSchema as z.ZodTypeAny, functions); fields.push(field); } return fields; } /** Analyze a single field */ function analyzeField(name: string, schema: z.ZodTypeAny, functions?: Record): FieldInfo { let required = true; let unwrapped = schema; // Unwrap optional/nullable/default if (isZodOptional(schema)) { required = false; const inner = getInnerSchema(schema); if (inner) unwrapped = inner as z.ZodTypeAny; } if (isZodNullable(unwrapped)) { required = false; const inner = getInnerSchema(unwrapped); if (inner) unwrapped = inner as z.ZodTypeAny; } if (isZodDefault(unwrapped)) { required = false; const inner = getInnerSchema(unwrapped); if (inner) unwrapped = inner as z.ZodTypeAny; } // Check for userContext const isUserContext = hasUserContextMetadata(schema) || hasUserContextMetadata(unwrapped); // Check for organizationContext const isOrganizationContext = hasOrganizationContextMetadata(schema) || hasOrganizationContextMetadata(unwrapped); // Check for fieldFrom const fieldFromMeta = getFieldFromMetadata(schema) || getFieldFromMetadata(unwrapped); const fieldFrom = fieldFromMeta?.functionName; // Check if fieldFrom source function is query-based (has 'query' input) let isQueryBased = false; if (fieldFrom && functions && functions[fieldFrom]) { const sourceInputs = functions[fieldFrom].inputs; if (isZodObject(sourceInputs)) { const sourceShape = getObjectShape(sourceInputs); if (sourceShape && 'query' in sourceShape) { isQueryBased = true; } } } // Get JSON schema representation let jsonSchema: Record = { type: "unknown" }; try { jsonSchema = z.toJSONSchema(unwrapped, { reused: "inline", unrepresentable: "any" }) as Record; delete jsonSchema.$schema; } catch { // Ignore schema conversion errors } // Get inner schema for userContext fields (the shape of the expected object) let innerSchema: Record | undefined; if (isUserContext && isZodObject(unwrapped)) { innerSchema = jsonSchema; } // Get inner schema for organizationContext fields (the shape of the expected object) if (isOrganizationContext && isZodObject(unwrapped)) { innerSchema = jsonSchema; } // Determine field type let type = (jsonSchema.type as string) || "unknown"; if (fieldFrom) { type = "fieldFrom"; } else if (isUserContext) { type = "userContext"; } else if (isOrganizationContext) { type = "organizationContext"; } else if (jsonSchema.enum) { type = "enum"; } return { name, type, required, isUserContext, isOrganizationContext, fieldFrom, isQueryBased: isQueryBased || undefined, schema: jsonSchema, innerSchema, }; } export async function startBrowserServer(options: BrowserServerOptions): Promise { const { config, diff = null, configDir, configPath, port: preferredPort, openBrowser = true, background = false } = options; // Check if we're in a headless environment early const browserEnv = process.env.BROWSER; const isVsCodeBrowser = !!browserEnv && (browserEnv.includes("helpers/browser.sh") || browserEnv.includes("vscode")); const isHeadless = process.platform === "linux" && !process.env.DISPLAY && (!browserEnv || isVsCodeBrowser); console.log( `Environment: platform=${process.platform} display=${process.env.DISPLAY ?? ""} browser=${browserEnv ?? ""} isVsCodeBrowser=${isVsCodeBrowser} isHeadless=${isHeadless}` ); // Transform config to graph data and enhance with diff info const baseGraphData = transformToGraphData(config); const graphData = enhanceWithDiff(baseGraphData, diff); return new Promise(async (resolve) => { const app = new Hono(); // API: Get full graph data (enhanced with change status) app.get("/api/graph", (c) => c.json(graphData)); // API: Get diff data app.get("/api/diff", (c) => c.json(diff)); // API: Get node details app.get("/api/node/:type/:id", (c) => { const { type, id } = c.req.param(); const nodeId = `${type}:${id}`; const details = getNodeDetails(baseGraphData, nodeId); if (!details.node) { return c.json({ error: "Node not found" }, 404); } return c.json(details); }); // API: Search nodes app.get("/api/search", (c) => { const query = c.req.query("q") || ""; if (query.length < 1) { return c.json({ results: [] }); } const results = searchNodes(baseGraphData, query); return c.json({ results }); }); // API: Approve changes (only works if diff exists) app.post("/api/approve", async (c) => { if (!diff || !configDir) { return c.json({ error: "No changes to approve" }, 400); } try { await writeLockfile(configDir, diff.newOntology, diff.newHash); // Give time for response to be sent before resolving setTimeout(() => { resolve({ approved: true }); }, 500); return c.json({ success: true }); } catch (error) { return c.json( { error: "Failed to write lockfile", message: error instanceof Error ? error.message : "Unknown error", }, 500 ); } }); // API: Reject changes app.post("/api/reject", (c) => { // Give time for response to be sent before resolving setTimeout(() => { resolve({ approved: false }); }, 500); return c.json({ success: true }); }); // API: Get raw TypeScript source app.get("/api/source", (c) => { if (!configPath) { return c.json({ error: "Config path not available" }, 400); } try { const source = readFileSync(configPath, "utf-8"); const filename = basename(configPath); return c.json({ source, filename, path: configPath }); } catch (error) { return c.json( { error: "Failed to read config file", message: error instanceof Error ? error.message : "Unknown error", }, 500 ); } }); // API: Get config info (environments, access groups) for test UI app.get("/api/config", (c) => { return c.json({ environments: Object.keys(config.environments), accessGroups: Object.keys(config.accessGroups), }); }); // API: Get function schema for form generation app.get("/api/function/:name/schema", (c) => { const { name } = c.req.param(); const fn = config.functions[name]; if (!fn) { return c.json({ error: "Function not found" }, 404); } try { const schema = analyzeInputSchema(fn.inputs, config.functions); return c.json({ name, description: fn.description, access: fn.access, entities: fn.entities, schema, }); } catch (error) { return c.json({ error: "Failed to analyze schema", message: error instanceof Error ? error.message : "Unknown error", }, 500); } }); // API: Load options for fieldFrom fields app.post("/api/function/:name/options", async (c) => { const { name } = c.req.param(); const fn = config.functions[name]; if (!fn) { return c.json({ error: "Function not found" }, 404); } try { const body = await c.req.json() as { env: string; accessGroups: string[]; query?: string }; const { env, accessGroups, query } = body; const envConfig = config.environments[env] || {}; const ctx: ResolverContext = { env, envConfig, accessGroups, logger: { info: console.log, warn: console.warn, error: console.error, debug: console.log, }, }; // Check if this is a query-based (autocomplete) function const shape = isZodObject(fn.inputs) ? getObjectShape(fn.inputs) : null; const hasQueryInput = shape && 'query' in shape; const args = hasQueryInput ? { query: query || '' } : {}; const result = await fn.resolver(ctx, args); return c.json({ options: result }); } catch (error) { return c.json({ error: "Failed to load options", message: error instanceof Error ? error.message : "Unknown error", }, 500); } }); // API: Execute function with mocked context (TEST MODE ONLY) app.post("/api/test/:name", async (c) => { const { name } = c.req.param(); const fn = config.functions[name]; if (!fn) { return c.json({ error: "Function not found" }, 404); } try { const body = await c.req.json() as { env: string; accessGroups: string[]; mockUserContext?: Record; inputs: Record; }; const { env, accessGroups, mockUserContext, inputs } = body; const envConfig = config.environments[env] || {}; const ctx: ResolverContext = { env, envConfig, accessGroups, logger: { info: console.log, warn: console.warn, error: console.error, debug: console.log, }, }; // Merge inputs with mocked user context const fullInputs = { ...inputs }; if (mockUserContext) { // Inject mocked user context values into the inputs for (const [key, value] of Object.entries(mockUserContext)) { fullInputs[key] = value; } } // Validate inputs const parseResult = fn.inputs.safeParse(fullInputs); if (!parseResult.success) { // Zod 4 uses .issues const issues = parseResult.error.issues || []; return c.json({ success: false, error: "Validation failed", validationErrors: issues.map((e) => ({ path: e.path.map(String).join('.'), message: e.message, })), }, 400); } // Execute resolver const startTime = Date.now(); const result = await fn.resolver(ctx, parseResult.data); const executionTime = Date.now() - startTime; return c.json({ success: true, result, executionTime, }); } catch (error) { return c.json({ success: false, error: error instanceof Error ? error.message : "Unknown error", stack: error instanceof Error ? error.stack : undefined, }, 500); } }); // Serve UI (use bundled browser-app HTML instead of generateBrowserUI) app.get("/", (c) => c.html(browserAppHtml)); // Start server const port = preferredPort || (await findAvailablePort(3457)); const server = await serve(app, port); const url = `http://localhost:${server.port}`; const hasChanges = diff?.hasChanges ?? false; console.log(`\nOntology ${hasChanges ? "Review" : "Browser"} available at: ${url}`); if (openBrowser && !background && !isHeadless) { console.log("Opening in browser...\n"); try { await open(url); } catch (error) { // Silently handle browser open failures console.log("Could not open browser automatically."); console.log(`Please open ${url} manually.\n`); } } else if (openBrowser && (background || isHeadless)) { console.log(`Please open ${url} in your browser.\n`); } if (hasChanges) { if (background) { console.log("Review UI running in background at " + url); console.log("Approve changes to update the lockfile.\n"); // Resolve immediately in background mode - server keeps running resolve({}); } else { console.log("Waiting for review decision...\n"); } } else { console.log("Press Ctrl+C to stop the server.\n"); // If no changes, resolve immediately with no approval status // But keep server running for browsing } }); } function generateBrowserUI(graphData: EnhancedGraphData): string { // For now, return the embedded HTML with inline data // In the future, this could be updated to use the React app // by serving static files and fetching data via /api/graph const userContextFilterBtn = graphData.meta.totalUserContextFunctions > 0 ? `` : ''; const orgContextFilterBtn = graphData.meta.totalOrganizationContextFunctions > 0 ? `` : ''; return ` ${graphData.meta.ontologyName} - Ontology Browser
Test Function Test Mode
${userContextFilterBtn} ${orgContextFilterBtn}
ontology.config.ts
Loading...
`; }