/** * The automation interface seam. * * Every automation-specific datum the host's surfaces render — navigation and * page copy, the settable attributes, the import/export envelope, endpoint * paths, the featured and responder id lists, the sub-page surface — is served * from the interface manifest the pinned `@openhands/extensions` publishes. * The host holds none of it. When the package publishes no manifest, or the * published one fails admission, there is nothing to serve: the nav entries do * not render and the routes 404. `hasAutomationInterface()` is that gate, and * every accessor that needs the manifest is reachable only from behind it. * * Routes are the exception, and they are not a definition: `src/routes.ts` * mounts them, so the host states them here and admission checks a manifest's * routes against them. That check is what keeps a published deep link * resolving to the page it named. */ import { AUTOMATION_CATALOG } from "@openhands/extensions/automations"; import { validateInterfaceManifest } from "./interface-validation"; import { AUTOMATION_INTERFACE_CANDIDATE } from "./manifest-sources"; import type { AutomationAttributeName, InterfaceDashboardFilter, InterfaceDashboardSort, InterfaceEndpointName, InterfaceIconSlug, InterfaceImportExport, InterfaceListInsights, InterfaceManifest, InterfaceOverview, InterfaceRoutes, InterfaceSubPageId, InterfaceTemplatesPage, } from "./types"; /** The routes this host has registrations for, in `src/routes.ts`. */ const MOUNTED_ROUTES = { list: "/automations", setup: "/automations/new/:automationId", detail: "/automations/:automationId", templates: "/automations/templates", } satisfies InterfaceRoutes; /** * File-format section for portable `.automation.json` export/import. A docs * pointer for the host's import picker, not interface data — the manifest's * `importExport` block has no docs field. */ export const AUTOMATION_FILE_FORMAT_DOCS_URL = "https://docs.openhands.dev/openhands/usage/agent-canvas/managing-automations#exported-file-format"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function getCatalogIds(): ReadonlySet { return new Set( (AUTOMATION_CATALOG as readonly unknown[]).flatMap((entry) => isRecord(entry) && typeof entry.id === "string" ? [entry.id] : [], ), ); } function admitInterfaceManifest(candidate: unknown): InterfaceManifest | null { if (candidate === undefined) return null; const result = validateInterfaceManifest(candidate, { catalogIds: getCatalogIds(), mountedRoutes: MOUNTED_ROUTES, }); if (!result.valid) { // Mirrors the setup registry: a rejected manifest is skipped loudly, and // the surfaces it would have described stay unrendered. console.warn( "Rejected the automation interface manifest:", result.errors.join("; "), ); return null; } return candidate as InterfaceManifest; } const ADMITTED = admitInterfaceManifest(AUTOMATION_INTERFACE_CANDIDATE); /** * Whether this deployment has an automation interface at all. The nav entries * and the route loaders ask before rendering; nothing else has to. */ export function hasAutomationInterface(): boolean { return ADMITTED !== null; } /** * The admitted manifest, for the accessors that cannot answer without one. * Reaching one of those without a manifest means a surface rendered past its * gate — a wiring mistake, not a state to render. */ function requireInterface(): InterfaceManifest { if (!ADMITTED) { throw new Error( "No automation interface manifest is admitted, so this surface should not have rendered.", ); } return ADMITTED; } function substituteRouteParam(pattern: string, id: string): string { return pattern.replace(":automationId", encodeURIComponent(id)); } export function automationListPath(): string { return MOUNTED_ROUTES.list; } export function automationSetupPath(id: string): string { return substituteRouteParam(MOUNTED_ROUTES.setup, id); } export function automationDetailPath(id: string): string { return substituteRouteParam(MOUNTED_ROUTES.detail, id); } export function automationTemplatesPath(): string { return MOUNTED_ROUTES.templates; } /** * Whether `path` (a location pathname) is inside the automation surface: the * list route itself or anything nested under it (detail, setup, templates, * git-sync). Prefix-safe: "/automations-foo" does not match. */ export function isAutomationsRoute(path: string): boolean { return ( path === MOUNTED_ROUTES.list || path.startsWith(`${MOUNTED_ROUTES.list}/`) ); } /** * A declared endpoint path. Empty for one the manifest may omit - the two a * bundle needs were added after the block shipped - so a caller that needs one * says so rather than reading a host-held default that does not exist. */ export function getAutomationEndpoint(name: InterfaceEndpointName): string { return requireInterface().endpoints[name] ?? ""; } /** An id-parameterized endpoint with `{id}` substituted, encoded. */ export function getAutomationIdEndpoint( name: "detail" | "dispatch" | "runs" | "tarball", id: string, ): string { return getAutomationEndpoint(name).replace("{id}", encodeURIComponent(id)); } export interface InterfaceCopy { sidebarLabel: string; commandMenuTitle: string; commandMenuDescription: string; commandMenuKeywords: string; listTitle: string; listSubtitle: string; detailBackLabel: string; editTitle: string; } export function getInterfaceCopy(): InterfaceCopy { const manifest = requireInterface(); return { sidebarLabel: manifest.navigation.sidebar.label, commandMenuTitle: manifest.navigation.commandMenu.title, commandMenuDescription: manifest.navigation.commandMenu.description, commandMenuKeywords: manifest.navigation.commandMenu.keywords, listTitle: manifest.pages.list.title, listSubtitle: manifest.pages.list.subtitle, detailBackLabel: manifest.pages.detail.backLabel, editTitle: manifest.pages.edit.title, }; } export interface AttributeSpec { present: boolean; label: string; /** Null when the manifest states no help text for the attribute. */ help: string | null; required: boolean; min: number | null; max: number | null; } /** An attribute the manifest does not declare is not offered at all. */ const ABSENT_ATTRIBUTE: AttributeSpec = { present: false, label: "", help: null, required: false, min: null, max: null, }; export function getAttributeSpec(name: AutomationAttributeName): AttributeSpec { const attribute = requireInterface().attributes[name]; if (!attribute) return ABSENT_ATTRIBUTE; return { present: true, label: attribute.label, help: attribute.help ?? null, required: attribute.required, min: attribute.constraints?.min ?? null, max: attribute.constraints?.max ?? null, }; } export function getImportExportSpec(): InterfaceImportExport { return requireInterface().importExport; } export function getAutomationsDocsUrl(): string { return requireInterface().docsUrl; } export function getFeaturedAutomationIds(): readonly string[] { return requireInterface().featuredAutomationIds; } export function getResponderIntegrationIds(): readonly string[] { return requireInterface().responderIntegrationIds; } export interface SubPageNavSpec { page: InterfaceSubPageId; /** The page's route, resolved through the host's route table. */ to: string; label: string; icon: InterfaceIconSlug; } /** * The sub-page navigation, or null when the manifest does not declare the * sub-page surface. */ export function getSubPagesSpec(): SubPageNavSpec[] | null { const subPages = ADMITTED?.navigation.subPages; if (!subPages) return null; return subPages.map((item) => ({ page: item.page, to: item.page === "templates" ? automationTemplatesPath() : automationListPath(), label: item.label, icon: item.icon, })); } export interface DashboardSpec { overview: InterfaceOverview; filters: InterfaceDashboardFilter[]; sort: InterfaceDashboardSort; insights: InterfaceListInsights; } /** * The list page's dashboard composition, or null when the manifest does not * declare it. Admission accepts the sub-page surface whole or not at all, so * these four are present together. */ export function getDashboardSpec(): DashboardSpec | null { const list = ADMITTED?.pages.list; if (!list?.overview || !list.filters || !list.sort || !list.insights) { return null; } return { overview: list.overview, filters: list.filters, sort: list.sort, insights: list.insights, }; } /** The templates page identity, or null when the manifest does not declare it. */ export function getTemplatesPageSpec(): InterfaceTemplatesPage | null { return ADMITTED?.pages.templates ?? null; }