openhands / src /manifests /automation-interface.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
3d700dd verified
Raw History Blame Contribute Delete
9 kB
/**
* 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getCatalogIds(): ReadonlySet<string> {
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;
}