From b46b2eb024bf8d43f29d3f1531ae57169036225b Mon Sep 17 00:00:00 2001 From: hieptl Date: Fri, 31 Jul 2026 17:35:28 +0700 Subject: [PATCH 1/2] feat: add the production interface manifest --- automations/README.md | 62 ++++- automations/catalog.schema.json | 5 +- .../catalog/github-pr-reviewer/manifest.json | 3 +- .../catalog/github-repo-monitor/manifest.json | 3 +- automations/index.d.ts | 117 ++++++++- automations/index.js | 7 + automations/interface.json | 72 ++++++ automations/interface.schema.json | 244 ++++++++++++++++++ index.d.ts | 14 +- index.js | 2 +- package.json | 2 + tests/test_interface_manifest.py | 107 ++++++++ 12 files changed, 623 insertions(+), 15 deletions(-) create mode 100644 automations/interface.json create mode 100644 automations/interface.schema.json create mode 100644 tests/test_interface_manifest.py diff --git a/automations/README.md b/automations/README.md index 74575529..ed18d459 100644 --- a/automations/README.md +++ b/automations/README.md @@ -9,6 +9,8 @@ uploaded to the automations service as a `.tar.gz`, belongs in the same director catalog//manifest.json one automation per directory - card metadata plus an optional `setup` block catalog.schema.json the contract, JSON Schema draft 2020-12 catalog-index.js generated from catalog/ - do not edit by hand +interface.json the production Automation interface manifest - domain-level, not per-entry +interface.schema.json its contract, JSON Schema draft 2020-12 index.js the stable API for Node.js and bundlers index.d.ts the public TypeScript shape ``` @@ -117,6 +119,11 @@ An assisted entry declares no triggers, because the trigger is settled during th - **`mode: "assisted"`** declares a `message`: setup context handed to an agent conversation that finishes the job. The command that opens that conversation comes from the skill, so it is not repeated here. +A direct entry may also declare a `message`. It is the seed for the fallback conversation the host offers +when the deployment cannot run the direct path - a deployment whose capabilities lack the entry's trigger +kind or required features. The same 2000-character cap applies, and because the fallback fires before the +form is trustworthy, a direct `message` should not reference `{{form.*}}`. + A form field is named after the property it fills. `schedule` and `timezone` under `triggers.cron` become `trigger.schedule` and `trigger.timezone`; `on` under `triggers.event` becomes `trigger.on`; a field named `ref` becomes `repos[].ref`. Any other field under a trigger kind, such as a phrase to match, is an input @@ -162,6 +169,44 @@ a webhook variant is out of scope, while its `setup` block creates the webhook f supports. Both statements are accurate about their own generation. Retiring the skill path for entries that ship a `setup` block belongs to whoever promotes this to production. +## The interface manifest (`interface.json`) + +The catalog states what varies per automation. `interface.json` states the domain-level facts of the +production Automation interface - the things that vary between *domains* (automations today, another +extension surface tomorrow) rather than between entries, so each is stated once rather than eight times. +Agent Canvas keeps its rendering components and reads every automation-specific datum from this file, +falling back to its built-in defaults when the manifest is absent or fails admission: + +- **`routes`** - the list, setup, and detail routes. The host must have a registration serving each + declared shape, so admission verifies they match what it mounted; the manifest is the single source for + link construction. +- **`navigation`** - the sidebar entry and the command-menu entry. +- **`pages`** - page-identity copy: the list title and subtitle, the detail back label. Generic chrome - + buttons, toasts, empty states, validation sentences - stays host copy, rendered through the host's + translations. +- **`docsUrl`** - the automations documentation link, prefix-pinned to docs.openhands.dev by schema. +- **`edit`** - which Automation properties the edit dialog offers, keyed by the property each field edits + (`name`, `prompt`, `model`, `timeout`, `schedule`), with labels, help, and numeric bounds. `schedule` is + a semantic type: the host owns the frequency/weekday/time composite it renders, as it owns the setup + form's `cron` type. +- **`importExport`** - the export file envelope (`kind`, `version`, filename suffix) and the two facts an + import cannot derive from the file: the provider inferred for short repository URLs, and the placeholder + event source that keeps a half-imported automation inert until its real trigger is applied. +- **`endpoints`** - service-relative paths the host calls. Relative paths only: the base path, methods, + headers, and auth remain the host's, so this cannot express a request to anywhere the host did not + choose. `{id}` marks where the host substitutes the automation id. +- **`featuredAutomationIds`** / **`responderIntegrationIds`** - the catalog entries the list page surfaces + as proven, and the integrations whose automations get the responder deployment-choice dialog. + +`interface.schema.json` is the authoritative contract, under the same trust rules as the catalog: no +markup in copy, no free-form URLs, closed key sets, no credentials. This narrowly reverses the `routes` +and `submit` deletions recorded below **at the domain level only** - per-entry data still follows the +deletion table. + +Changing a route or endpoint here still requires a host release that serves the declared shape, so the +Distribution open question applies unchanged. The contract fixtures pin `/v1/preset/prompt` as the create +endpoint; changing `endpoints.createPrompt` must regenerate them in the same release. + ## Contract fixtures The fixtures are independent test vectors, not part of the runtime catalog. They live in @@ -201,11 +246,12 @@ Two findings came out of that check and are baked into the entries: | Endpoint | Status | | --- | --- | | `POST /v1/preset/prompt` | Exists | -| `GET /v1/capabilities` | Does not exist. Defining it is OpenHands/automation#262 | -| `POST /v1/validate` | Does not exist. Defining it is OpenHands/automation#262 | +| `GET /v1/capabilities` | Exists (OpenHands/automation#270) | +| `POST /v1/validate` | Exists (OpenHands/automation#270) | -None of these appear in a manifest. They are the host's, which is why settling on different paths changes -no catalog entry at all. +None of these appear in a catalog entry. Their service-relative paths are stated once, in +`interface.json`'s `endpoints` block, which is why settling on different paths changes no catalog entry at +all. ## Deviations from the project reference document @@ -217,7 +263,7 @@ questions to this work. What changed and why: | A separate manifest file per automation | Merged into the catalog entry as `setup`, so there is one hand-authored record per automation and nothing to keep in sync. | | `prompt` | Removed. It duplicated the slash command already declared in the skill's `triggers:` frontmatter. The entry names the `skill` instead, defaulting to `id`, and the command is looked up from the skills catalog. | | `requiredIntegrationIds` | Removed. It duplicated the integration ids in `requires`, which now sits on the entry so every automation carries them once. | -| `routes` | Removed. The only route is `/automations/new/`. | +| `routes` | Removed from entries. The route table is a domain-level fact, stated once in `interface.json`. | | `capabilities` | Removed. The discovery call is the same for every automation, and the block's only entry-specific content was a feature list, now `requires.features`. | | `capabilities.bindings` | Removed. The `cron` and `timezone` field types tell the host to resolve the deployment's limits on their own. | | `requires.secrets` | Removed. An automation that needs GitHub declares the GitHub integration; the credential comes with the connection rather than being asked for twice. | @@ -227,7 +273,7 @@ questions to this work. What changed and why: | `form.fields` | Split into `form.triggers` and `form.args`, and both keyed by field name so the name is the key rather than a repeated property. | | `validation` | Removed. The preflight call has the same shape for every automation, and the payload-path mapping is recovered by walking `payload`. | | `review` | Removed. The confirmation screen is the declared fields and their labels. | -| `submit` | Reduced to the parts that vary: `prompt` and, for an event trigger, `filter` for direct; `message` for assisted. The action, endpoint, success navigation, and error handling are identical everywhere. | +| `submit` | Reduced to the parts that vary: `prompt` and, for an event trigger, `filter` for direct; `message` for assisted (and, on direct entries, as the fallback-conversation seed). The action, success navigation, and error handling are identical everywhere; the endpoints are domain-level and live in `interface.json`. | | `submit.payload` | Removed. `name`, `repos` and `trigger` all restated the form, so they are rebuilt from it. What is left is the prompt and the event filter. | | `analytics` | Removed. The same stages fire for every automation, so they belong in shared host code. | | `workflow.steps` | Removed. It restated which keys were present, creating a second source of truth that could contradict the file. | @@ -253,6 +299,8 @@ Recorded rather than resolved, because they need an owner outside this contract: related fields onto one line, which reads better; if that matters, it is a host concern, not an entry's. - **Assisted-setup completion.** The assisted flow ends at a conversation, so the host cannot emit a completion event. Until something reports back, the ratio of direct to assisted setup is not measurable. -- **Edit and delete routes.** Only the `new` route is modelled here. +- **Delete confirmation and per-run views.** `interface.json` models the list, setup, and detail routes + and the edit dialog's fields; deletion, run logs, and the dashboard sub-pages remain host-owned surfaces + with no manifest data of their own yet. - **Types from the schema.** `index.d.ts` is hand-written and mirrors `catalog.schema.json`. Generating it would remove that second source of truth. diff --git a/automations/catalog.schema.json b/automations/catalog.schema.json index 2ace5e32..e0d42803 100644 --- a/automations/catalog.schema.json +++ b/automations/catalog.schema.json @@ -68,7 +68,7 @@ "$ref": "#/$defs/templateValue" }, "message": { - "description": "assisted only. Setup context handed to the agent conversation that finishes setup. The command that opens it comes from the skill, so it is not repeated here. Capped so this never becomes a channel for runtime instructions.", + "description": "Setup context handed to the agent conversation that finishes setup. Required for assisted mode. Optional for direct mode, where it seeds the fallback conversation the host offers when the deployment cannot run the direct path. The command that opens it comes from the skill, so it is not repeated here. Capped so this never becomes a channel for runtime instructions.", "allOf": [{ "$ref": "#/$defs/templateCopy" }, { "maxLength": 2000 }] } }, @@ -79,8 +79,7 @@ "required": ["mode"] }, "then": { - "required": ["prompt"], - "not": { "required": ["message"] } + "required": ["prompt"] } }, { diff --git a/automations/catalog/github-pr-reviewer/manifest.json b/automations/catalog/github-pr-reviewer/manifest.json index 84428f5d..cb0f41a9 100644 --- a/automations/catalog/github-pr-reviewer/manifest.json +++ b/automations/catalog/github-pr-reviewer/manifest.json @@ -77,6 +77,7 @@ } } }, - "prompt": "Review pull requests labeled '{{form.triggerLabel}}' in {{form.repository}}. Review tone: {{form.reviewTone}}." + "prompt": "Review pull requests labeled '{{form.triggerLabel}}' in {{form.repository}}. Review tone: {{form.reviewTone}}.", + "message": "This deployment cannot run the scheduled review automation directly. Set it up in this conversation instead: confirm the repository to review, the trigger label, the review tone, and the polling schedule, then create the automation." } } diff --git a/automations/catalog/github-repo-monitor/manifest.json b/automations/catalog/github-repo-monitor/manifest.json index 41aa031d..25f85e01 100644 --- a/automations/catalog/github-repo-monitor/manifest.json +++ b/automations/catalog/github-repo-monitor/manifest.json @@ -77,6 +77,7 @@ } }, "prompt": "A comment in {{form.repository}} mentions '{{form.triggerPhrase}}'. Read the surrounding issue or pull request context from the event payload, then post a helpful reply as a comment on the same thread.", - "filter": "icontains(comment.body, '{{form.triggerPhrase}}') && glob(repository.full_name, '{{form.repository}}')" + "filter": "icontains(comment.body, '{{form.triggerPhrase}}') && glob(repository.full_name, '{{form.repository}}')", + "message": "This deployment cannot run the webhook-driven monitor directly. Set it up in this conversation instead: confirm the repository to watch, the comment event, and the trigger phrase, then create the automation. If webhook delivery is unavailable here, build the polling variant this skill supports." } } diff --git a/automations/index.d.ts b/automations/index.d.ts index c2077e15..aa1c580e 100644 --- a/automations/index.d.ts +++ b/automations/index.d.ts @@ -104,10 +104,125 @@ export interface AutomationSetup { prompt?: string; /** direct only, event trigger only. Which delivered events belong to it. */ filter?: string; - /** assisted only. Setup context for the conversation that finishes setup. */ + /** + * Setup context for the conversation that finishes setup. Required for + * assisted mode. Optional for direct mode, where it seeds the fallback + * conversation offered when the deployment cannot run the direct path. + */ message?: string; } +/** + * The production Automation interface manifest. + * + * Mirrors `automations/interface.schema.json`, which is authoritative. The + * catalog states what varies per automation; this states the domain-level + * facts of the interface itself: routes, navigation, page-identity copy, the + * edit form, the import/export envelope, the service-relative endpoints, and + * the featured and responder id lists. The host validates it at admission and + * falls back to its built-in defaults when it is absent or rejected. + */ + +export interface AutomationInterfaceRoutes { + list: string; + /** Carries the `:automationId` segment the host substitutes. */ + setup: string; + /** Carries the `:automationId` segment the host substitutes. */ + detail: string; +} + +export interface AutomationInterfaceNavigation { + sidebar: { label: string }; + commandMenu: { title: string; description: string; keywords: string }; +} + +export interface AutomationInterfacePages { + list: { title: string; subtitle: string }; + detail: { backLabel: string }; +} + +export type AutomationEditFieldType = + | "text" + | "textarea" + | "number" + | "llm-profile" + | "schedule"; + +/** The closed set of Automation properties the edit dialog may offer. */ +export type AutomationEditableProperty = + | "name" + | "prompt" + | "model" + | "timeout" + | "schedule"; + +export interface AutomationEditFieldConstraints { + min?: number; + max?: number; +} + +export interface AutomationEditField { + type: AutomationEditFieldType; + label: string; + help?: string; + required: boolean; + /** Only a `number` field carries constraints. */ + constraints?: AutomationEditFieldConstraints; +} + +export interface AutomationInterfaceEdit { + title: string; + /** Keyed by the Automation property each field edits. */ + fields: Partial>; +} + +export interface AutomationImportDefaults { + /** Provider inferred for short owner/repo repository URLs on import. */ + repoProvider: AutomationGitProvider; + /** Event source of the placeholder trigger that keeps an import inert. */ + placeholderEventSource: string; +} + +export interface AutomationInterfaceImportExport { + fileKind: string; + fileVersion: 1; + filenameSuffix: string; + importDefaults: AutomationImportDefaults; +} + +/** + * Service-relative paths the host calls. Relative paths only: the base path, + * methods, headers, and auth remain the host's. `{id}` marks where the host + * substitutes the automation id. + */ +export interface AutomationInterfaceEndpoints { + list: string; + detail: string; + dispatch: string; + runs: string; + tarball: string; + health: string; + capabilities: string; + validate: string; + createPrompt: string; + createPlugin: string; +} + +export interface AutomationInterfaceManifest { + version: "1.0"; + routes: AutomationInterfaceRoutes; + navigation: AutomationInterfaceNavigation; + pages: AutomationInterfacePages; + docsUrl: string; + edit: AutomationInterfaceEdit; + importExport: AutomationInterfaceImportExport; + endpoints: AutomationInterfaceEndpoints; + featuredAutomationIds: string[]; + responderIntegrationIds: string[]; +} + +export const AUTOMATION_INTERFACE: AutomationInterfaceManifest; + export const AUTOMATION_CATALOG: RecommendedAutomation[]; /** * Return the full automation catalog. diff --git a/automations/index.js b/automations/index.js index 4ae220e1..ec3dfe02 100644 --- a/automations/index.js +++ b/automations/index.js @@ -6,6 +6,7 @@ * package can statically import each JSON file without an aggregate JSON asset. */ import { AUTOMATION_CATALOG_ENTRIES } from "./catalog-index.js"; +import interfaceManifest from "./interface.json" with { type: "json" }; const clone = (value) => JSON.parse(JSON.stringify(value)); const AUTOMATIONS = AUTOMATION_CATALOG_ENTRIES; @@ -20,4 +21,10 @@ export const getAutomationCatalogEntry = (id) => { export const AUTOMATION_CATALOG = clone(AUTOMATIONS); +/** + * The production Automation interface manifest: the domain-level facts of the + * interface, hand-authored in `automations/interface.json`. + */ +export const AUTOMATION_INTERFACE = clone(interfaceManifest); + export default AUTOMATION_CATALOG; diff --git a/automations/interface.json b/automations/interface.json new file mode 100644 index 00000000..152f415c --- /dev/null +++ b/automations/interface.json @@ -0,0 +1,72 @@ +{ + "version": "1.0", + "routes": { + "list": "/automations", + "setup": "/automations/new/:automationId", + "detail": "/automations/:automationId" + }, + "navigation": { + "sidebar": { "label": "Automate" }, + "commandMenu": { + "title": "Automations", + "description": "Review scheduled and webhook automations.", + "keywords": "automate cron schedule webhook jobs" + } + }, + "pages": { + "list": { + "title": "Automations", + "subtitle": "View active and inactive automations, search by metadata, and inspect read-only details." + }, + "detail": { "backLabel": "Back to Automations" } + }, + "docsUrl": "https://docs.openhands.dev/openhands/usage/automations/overview", + "edit": { + "title": "Edit automation", + "fields": { + "name": { "type": "text", "label": "Name", "required": true }, + "prompt": { + "type": "textarea", + "label": "Prompt", + "help": "Edits apply to future runs only.", + "required": false + }, + "model": { "type": "llm-profile", "label": "LLM profile", "required": false }, + "timeout": { + "type": "number", + "label": "Timeout (seconds)", + "help": "Maximum time a single run may take. Leave empty for the default of 600 seconds (10 minutes); maximum 1800 seconds (30 minutes).", + "required": false, + "constraints": { "min": 1, "max": 1800 } + }, + "schedule": { "type": "schedule", "label": "Frequency", "required": false } + } + }, + "importExport": { + "fileKind": "automation", + "fileVersion": 1, + "filenameSuffix": ".automation.json", + "importDefaults": { + "repoProvider": "github", + "placeholderEventSource": "agent-canvas-import" + } + }, + "endpoints": { + "list": "/v1", + "detail": "/v1/{id}", + "dispatch": "/v1/{id}/dispatch", + "runs": "/v1/{id}/runs", + "tarball": "/v1/{id}/tarball", + "health": "/health", + "capabilities": "/v1/capabilities", + "validate": "/v1/validate", + "createPrompt": "/v1/preset/prompt", + "createPlugin": "/v1/preset/plugin" + }, + "featuredAutomationIds": [ + "github-pr-reviewer", + "github-repo-monitor", + "slack-channel-monitor" + ], + "responderIntegrationIds": ["github", "slack"] +} diff --git a/automations/interface.schema.json b/automations/interface.schema.json new file mode 100644 index 00000000..881a2df6 --- /dev/null +++ b/automations/interface.schema.json @@ -0,0 +1,244 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/OpenHands/extensions/automations/interface.schema.json", + "title": "AutomationInterfaceManifest", + "description": "Strict schema for automations/interface.json. Mirrors the AutomationInterfaceManifest types in automations/index.d.ts. The catalog states what varies per automation; this file states the domain-level facts of the production Automation interface - routes, navigation, page-identity copy, the edit form, the import/export envelope, the service-relative endpoints, and the featured and responder id lists. The host renders it; it is data, not code - no markup in copy, no free-form URL, no host name, method, or credential.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "routes", + "navigation", + "pages", + "docsUrl", + "edit", + "importExport", + "endpoints", + "featuredAutomationIds", + "responderIntegrationIds" + ], + "properties": { + "version": { + "const": "1.0", + "description": "Selects how this manifest is interpreted. A future format ships a new constant." + }, + "routes": { + "description": "The routes of the Automation interface. The host must have a registration serving each declared shape, so admission verifies they match what it mounted; the manifest is the single source for link construction.", + "type": "object", + "additionalProperties": false, + "required": ["list", "setup", "detail"], + "properties": { + "list": { "$ref": "#/$defs/routePath" }, + "setup": { "$ref": "#/$defs/parameterizedRoutePath" }, + "detail": { "$ref": "#/$defs/parameterizedRoutePath" } + } + }, + "navigation": { + "type": "object", + "additionalProperties": false, + "required": ["sidebar", "commandMenu"], + "properties": { + "sidebar": { + "type": "object", + "additionalProperties": false, + "required": ["label"], + "properties": { "label": { "$ref": "#/$defs/copy" } } + }, + "commandMenu": { + "type": "object", + "additionalProperties": false, + "required": ["title", "description", "keywords"], + "properties": { + "title": { "$ref": "#/$defs/copy" }, + "description": { "$ref": "#/$defs/copy" }, + "keywords": { "$ref": "#/$defs/copy" } + } + } + } + }, + "pages": { + "description": "Page-identity copy. Generic chrome - buttons, toasts, empty states, validation sentences - stays host copy, rendered through the host's translations.", + "type": "object", + "additionalProperties": false, + "required": ["list", "detail"], + "properties": { + "list": { + "type": "object", + "additionalProperties": false, + "required": ["title", "subtitle"], + "properties": { + "title": { "$ref": "#/$defs/copy" }, + "subtitle": { "$ref": "#/$defs/copy" } + } + }, + "detail": { + "type": "object", + "additionalProperties": false, + "required": ["backLabel"], + "properties": { "backLabel": { "$ref": "#/$defs/copy" } } + } + } + }, + "docsUrl": { + "description": "The automations documentation link. Prefix-pinned so this cannot become an arbitrary link.", + "type": "string", + "pattern": "^https://docs\\.openhands\\.dev/[A-Za-z0-9/._-]*$" + }, + "edit": { + "description": "The edit dialog: which Automation properties it offers, keyed by the property each field edits. The closed key set is the editable surface of the runtime model; schedule is a semantic type whose frequency/weekday/time composite the host owns, like the setup form's cron type.", + "type": "object", + "additionalProperties": false, + "required": ["title", "fields"], + "properties": { + "title": { "$ref": "#/$defs/copy" }, + "fields": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { "$ref": "#/$defs/editField" }, + "prompt": { "$ref": "#/$defs/editField" }, + "model": { "$ref": "#/$defs/editField" }, + "timeout": { "$ref": "#/$defs/editField" }, + "schedule": { "$ref": "#/$defs/editField" } + } + } + } + }, + "importExport": { + "description": "The export file envelope, and the two facts an import cannot derive from the file: the provider inferred for short repository URLs and the placeholder event source that keeps a half-imported automation inert until its real trigger is applied.", + "type": "object", + "additionalProperties": false, + "required": ["fileKind", "fileVersion", "filenameSuffix", "importDefaults"], + "properties": { + "fileKind": { "type": "string", "pattern": "^[a-z][a-z-]*$" }, + "fileVersion": { "const": 1 }, + "filenameSuffix": { "type": "string", "pattern": "^\\.[a-z][a-z.]*json$" }, + "importDefaults": { + "type": "object", + "additionalProperties": false, + "required": ["repoProvider", "placeholderEventSource"], + "properties": { + "repoProvider": { "enum": ["github", "gitlab", "bitbucket"] }, + "placeholderEventSource": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9.-]*$" + } + } + } + } + }, + "endpoints": { + "description": "Service-relative paths the host calls. Relative paths only: the base path, methods, headers, and auth remain the host's, so this cannot express a request to anywhere the host did not choose. {id} marks where the host substitutes the automation id.", + "type": "object", + "additionalProperties": false, + "required": [ + "list", + "detail", + "dispatch", + "runs", + "tarball", + "health", + "capabilities", + "validate", + "createPrompt", + "createPlugin" + ], + "properties": { + "list": { "$ref": "#/$defs/plainEndpoint" }, + "detail": { "$ref": "#/$defs/idEndpoint" }, + "dispatch": { "$ref": "#/$defs/idEndpoint" }, + "runs": { "$ref": "#/$defs/idEndpoint" }, + "tarball": { "$ref": "#/$defs/idEndpoint" }, + "health": { "$ref": "#/$defs/plainEndpoint" }, + "capabilities": { "$ref": "#/$defs/plainEndpoint" }, + "validate": { "$ref": "#/$defs/plainEndpoint" }, + "createPrompt": { "$ref": "#/$defs/plainEndpoint" }, + "createPlugin": { "$ref": "#/$defs/plainEndpoint" } + } + }, + "featuredAutomationIds": { + "description": "Catalog entries the list page surfaces as proven. Each id must resolve to an automations/catalog entry; the host's admission checks that, mirroring how requires.integrations ids are checked by the catalog tests.", + "$ref": "#/$defs/slugList" + }, + "responderIntegrationIds": { + "description": "Integrations whose automations are treated as continuously-polling responders and get the deployment-choice dialog.", + "$ref": "#/$defs/slugList" + } + }, + "$defs": { + "copy": { + "description": "Literal user-visible copy. Carries no markup, because this manifest must never inject HTML into the host.", + "type": "string", + "minLength": 1, + "not": { "pattern": "<[A-Za-z/!]" } + }, + "routePath": { + "type": "string", + "pattern": "^/[A-Za-z0-9/:_-]*$", + "not": { "pattern": "//" } + }, + "parameterizedRoutePath": { + "description": "A route path carrying the :automationId segment the host substitutes.", + "allOf": [ + { "$ref": "#/$defs/routePath" }, + { "pattern": ":automationId" } + ] + }, + "endpointPath": { + "type": "string", + "pattern": "^/[A-Za-z0-9/{}_-]*$", + "not": { "pattern": "//" } + }, + "plainEndpoint": { + "allOf": [ + { "$ref": "#/$defs/endpointPath" }, + { "not": { "pattern": "[{}]" } } + ] + }, + "idEndpoint": { + "description": "An endpoint path with exactly one {id} substitution and no other braces.", + "allOf": [ + { "$ref": "#/$defs/endpointPath" }, + { "pattern": "^[^{}]*\\{id\\}[^{}]*$" } + ] + }, + "editField": { + "type": "object", + "additionalProperties": false, + "required": ["type", "label", "required"], + "properties": { + "type": { + "enum": ["text", "textarea", "number", "llm-profile", "schedule"] + }, + "label": { "$ref": "#/$defs/copy" }, + "help": { "$ref": "#/$defs/copy" }, + "required": { "type": "boolean" }, + "constraints": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "min": { "type": "integer", "minimum": 0 }, + "max": { "type": "integer", "minimum": 1 } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "not": { "const": "number" } } }, + "required": ["type"] + }, + "then": { "not": { "required": ["constraints"] } } + } + ] + }, + "slugList": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" } + } + } +} diff --git a/index.d.ts b/index.d.ts index 34552a98..d36dae6f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -13,7 +13,7 @@ export { type OAuthProviderCatalogOption, type OAuthProviderRegistrationDefaults, } from "./integrations/index.js"; -export { AUTOMATION_CATALOG } from "./automations/index.js"; +export { AUTOMATION_CATALOG, AUTOMATION_INTERFACE } from "./automations/index.js"; export type { AutomationFieldConstraints, AutomationFieldOption, @@ -29,6 +29,18 @@ export type { AutomationTriggerForm, AutomationTriggerKind, RecommendedAutomation, + AutomationEditableProperty, + AutomationEditField, + AutomationEditFieldConstraints, + AutomationEditFieldType, + AutomationImportDefaults, + AutomationInterfaceEdit, + AutomationInterfaceEndpoints, + AutomationInterfaceImportExport, + AutomationInterfaceManifest, + AutomationInterfaceNavigation, + AutomationInterfacePages, + AutomationInterfaceRoutes, } from "./automations/index.js"; export { SKILLS_CATALOG } from "./skills/index.js"; export type { SkillCatalogEntry } from "./skills/index.js"; diff --git a/index.js b/index.js index bc5205f3..946935c3 100644 --- a/index.js +++ b/index.js @@ -1,3 +1,3 @@ export { INTEGRATION_CATALOG } from "./integrations/index.js"; -export { AUTOMATION_CATALOG } from "./automations/index.js"; +export { AUTOMATION_CATALOG, AUTOMATION_INTERFACE } from "./automations/index.js"; export { SKILLS_CATALOG } from "./skills/index.js"; diff --git a/package.json b/package.json index 6176be20..881f2804 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ }, "./automations/catalog/*": "./automations/catalog/*", "./automations/catalog.schema.json": "./automations/catalog.schema.json", + "./automations/interface.json": "./automations/interface.json", + "./automations/interface.schema.json": "./automations/interface.schema.json", "./integrations": { "types": "./integrations/index.d.ts", "import": "./integrations/index.js", diff --git a/tests/test_interface_manifest.py b/tests/test_interface_manifest.py new file mode 100644 index 00000000..be3b0e97 --- /dev/null +++ b/tests/test_interface_manifest.py @@ -0,0 +1,107 @@ +"""The production Automation interface manifest and its schema. + +`interface.json` states the domain-level facts of the Automation interface; +`interface.schema.json` is its authoritative contract. These tests hold the +manifest to its schema, the schema to the same trust rules as the catalog's, +and the manifest's cross-file references to the catalog. +""" + +import copy +import json +import subprocess +from pathlib import Path + +import jsonschema +import pytest + +ROOT = Path(__file__).resolve().parents[1] +AUTOMATIONS = ROOT / "automations" +SCHEMA = json.loads((AUTOMATIONS / "interface.schema.json").read_text()) +MANIFEST = json.loads((AUTOMATIONS / "interface.json").read_text()) + + +def _validate(document): + jsonschema.Draft202012Validator(SCHEMA).validate(document) + + +def _with(mutation): + document = copy.deepcopy(MANIFEST) + mutation(document) + return document + + +def test_schema_is_a_valid_draft_2020_12_schema(): + jsonschema.Draft202012Validator.check_schema(SCHEMA) + + +def test_manifest_validates_against_its_schema(): + _validate(MANIFEST) + + +def test_featured_automations_resolve_to_catalog_entries(): + for automation_id in MANIFEST["featuredAutomationIds"]: + manifest = AUTOMATIONS / "catalog" / automation_id / "manifest.json" + assert manifest.is_file(), automation_id + + +@pytest.mark.parametrize( + ("case", "mutation"), + [ + ("an unknown version", lambda doc: doc.update(version="2.0")), + ( + "markup in copy", + lambda doc: doc["navigation"]["sidebar"].update( + label="Automate" + ), + ), + ( + "a docs link outside the product documentation", + lambda doc: doc.update(docsUrl="https://evil.example/"), + ), + ( + "an endpoint naming a host", + lambda doc: doc["endpoints"].update(list="https://evil.example/v1"), + ), + ( + "an id endpoint without its {id} substitution", + lambda doc: doc["endpoints"].update(detail="/v1/latest"), + ), + ( + "a key the contract does not define", + lambda doc: doc.update(dashboards=[]), + ), + ( + "constraints on a non-number edit field", + lambda doc: doc["edit"]["fields"]["name"].update( + constraints={"max": 50} + ), + ), + ], +) +def test_schema_rejects(case, mutation): + with pytest.raises(jsonschema.ValidationError): + _validate(_with(mutation)) + + +def test_direct_entries_carry_a_fallback_message(): + """Both direct entries seed the conversation the host offers when the + deployment cannot run the direct path, within the assisted-message cap.""" + for entry_id in ("github-pr-reviewer", "github-repo-monitor"): + manifest = json.loads( + (AUTOMATIONS / "catalog" / entry_id / "manifest.json").read_text() + ) + message = manifest["setup"].get("message", "") + assert message, entry_id + assert len(message) <= 2000, entry_id + + +def test_node_package_exports_the_interface(): + script = """ + import { AUTOMATION_INTERFACE } from './index.js'; + if (AUTOMATION_INTERFACE?.version !== '1.0') process.exit(1); + if (AUTOMATION_INTERFACE.endpoints.createPrompt !== '/v1/preset/prompt') process.exit(1); + if (AUTOMATION_INTERFACE.routes.setup !== '/automations/new/:automationId') process.exit(1); + """ + subprocess.run( + ["node", "--input-type=module", "-e", script], cwd=ROOT, check=True + ) From 78684c1ffaa7b48c363940f495f74319a19fab1e Mon Sep 17 00:00:00 2001 From: hieptl Date: Sat, 1 Aug 2026 01:22:23 +0700 Subject: [PATCH 2/2] refactor: update the code based on feedback --- automations/README.md | 17 ++++++------- automations/index.d.ts | 40 ++++++++++++++++++------------- automations/interface.json | 40 +++++++++++++++---------------- automations/interface.schema.json | 39 +++++++++++++++--------------- index.d.ts | 10 ++++---- tests/test_interface_manifest.py | 4 ++-- 6 files changed, 77 insertions(+), 73 deletions(-) diff --git a/automations/README.md b/automations/README.md index ed18d459..709bf4bb 100644 --- a/automations/README.md +++ b/automations/README.md @@ -181,14 +181,15 @@ falling back to its built-in defaults when the manifest is absent or fails admis declared shape, so admission verifies they match what it mounted; the manifest is the single source for link construction. - **`navigation`** - the sidebar entry and the command-menu entry. -- **`pages`** - page-identity copy: the list title and subtitle, the detail back label. Generic chrome - - buttons, toasts, empty states, validation sentences - stays host copy, rendered through the host's - translations. +- **`pages`** - page-identity copy: the list title and subtitle, the detail back label, the edit-dialog + title. Generic chrome - buttons, toasts, empty states, validation sentences - stays host copy, rendered + through the host's translations. - **`docsUrl`** - the automations documentation link, prefix-pinned to docs.openhands.dev by schema. -- **`edit`** - which Automation properties the edit dialog offers, keyed by the property each field edits - (`name`, `prompt`, `model`, `timeout`, `schedule`), with labels, help, and numeric bounds. `schedule` is - a semantic type: the host owns the frequency/weekday/time composite it renders, as it owns the setup - form's `cron` type. +- **`attributes`** - the input surface of an existing Automation: which attributes can be set after + creation, keyed by the runtime-model property the host sends (`name`, `prompt`, `model`, `timeout`, + `schedule`), with labels, help, and numeric bounds. How a client offers them - Agent Canvas renders an + edit dialog - is the client's choice, not stated here. `schedule` is a semantic type: the host owns the + frequency/weekday/time composite it renders, as it owns the setup form's `cron` type. - **`importExport`** - the export file envelope (`kind`, `version`, filename suffix) and the two facts an import cannot derive from the file: the provider inferred for short repository URLs, and the placeholder event source that keeps a half-imported automation inert until its real trigger is applied. @@ -300,7 +301,7 @@ Recorded rather than resolved, because they need an owner outside this contract: - **Assisted-setup completion.** The assisted flow ends at a conversation, so the host cannot emit a completion event. Until something reports back, the ratio of direct to assisted setup is not measurable. - **Delete confirmation and per-run views.** `interface.json` models the list, setup, and detail routes - and the edit dialog's fields; deletion, run logs, and the dashboard sub-pages remain host-owned surfaces + and the settable attributes; deletion, run logs, and the dashboard sub-pages remain host-owned surfaces with no manifest data of their own yet. - **Types from the schema.** `index.d.ts` is hand-written and mirrors `catalog.schema.json`. Generating it would remove that second source of truth. diff --git a/automations/index.d.ts b/automations/index.d.ts index aa1c580e..7d57153b 100644 --- a/automations/index.d.ts +++ b/automations/index.d.ts @@ -118,9 +118,10 @@ export interface AutomationSetup { * Mirrors `automations/interface.schema.json`, which is authoritative. The * catalog states what varies per automation; this states the domain-level * facts of the interface itself: routes, navigation, page-identity copy, the - * edit form, the import/export envelope, the service-relative endpoints, and - * the featured and responder id lists. The host validates it at admission and - * falls back to its built-in defaults when it is absent or rejected. + * settable attributes of an automation, the import/export envelope, the + * service-relative endpoints, and the featured and responder id lists. The + * host validates it at admission and falls back to its built-in defaults when + * it is absent or rejected. */ export interface AutomationInterfaceRoutes { @@ -139,42 +140,47 @@ export interface AutomationInterfaceNavigation { export interface AutomationInterfacePages { list: { title: string; subtitle: string }; detail: { backLabel: string }; + edit: { title: string }; } -export type AutomationEditFieldType = +export type AutomationAttributeType = | "text" | "textarea" | "number" | "llm-profile" | "schedule"; -/** The closed set of Automation properties the edit dialog may offer. */ -export type AutomationEditableProperty = +/** The closed set of runtime-model properties a client may offer for setting. */ +export type AutomationAttributeName = | "name" | "prompt" | "model" | "timeout" | "schedule"; -export interface AutomationEditFieldConstraints { +export interface AutomationAttributeConstraints { min?: number; max?: number; } -export interface AutomationEditField { - type: AutomationEditFieldType; +/** How one settable attribute of an existing Automation is offered. */ +export interface AutomationAttribute { + type: AutomationAttributeType; label: string; help?: string; required: boolean; - /** Only a `number` field carries constraints. */ - constraints?: AutomationEditFieldConstraints; + /** Only a `number` attribute carries constraints. */ + constraints?: AutomationAttributeConstraints; } -export interface AutomationInterfaceEdit { - title: string; - /** Keyed by the Automation property each field edits. */ - fields: Partial>; -} +/** + * The input surface of an existing Automation, keyed by the runtime-model + * property the host sends. How a client offers these - Agent Canvas renders + * an edit dialog - is the client's choice, not stated here. + */ +export type AutomationInterfaceAttributes = Partial< + Record +>; export interface AutomationImportDefaults { /** Provider inferred for short owner/repo repository URLs on import. */ @@ -214,7 +220,7 @@ export interface AutomationInterfaceManifest { navigation: AutomationInterfaceNavigation; pages: AutomationInterfacePages; docsUrl: string; - edit: AutomationInterfaceEdit; + attributes: AutomationInterfaceAttributes; importExport: AutomationInterfaceImportExport; endpoints: AutomationInterfaceEndpoints; featuredAutomationIds: string[]; diff --git a/automations/interface.json b/automations/interface.json index 152f415c..7da972e4 100644 --- a/automations/interface.json +++ b/automations/interface.json @@ -18,29 +18,27 @@ "title": "Automations", "subtitle": "View active and inactive automations, search by metadata, and inspect read-only details." }, - "detail": { "backLabel": "Back to Automations" } + "detail": { "backLabel": "Back to Automations" }, + "edit": { "title": "Edit automation" } }, "docsUrl": "https://docs.openhands.dev/openhands/usage/automations/overview", - "edit": { - "title": "Edit automation", - "fields": { - "name": { "type": "text", "label": "Name", "required": true }, - "prompt": { - "type": "textarea", - "label": "Prompt", - "help": "Edits apply to future runs only.", - "required": false - }, - "model": { "type": "llm-profile", "label": "LLM profile", "required": false }, - "timeout": { - "type": "number", - "label": "Timeout (seconds)", - "help": "Maximum time a single run may take. Leave empty for the default of 600 seconds (10 minutes); maximum 1800 seconds (30 minutes).", - "required": false, - "constraints": { "min": 1, "max": 1800 } - }, - "schedule": { "type": "schedule", "label": "Frequency", "required": false } - } + "attributes": { + "name": { "type": "text", "label": "Name", "required": true }, + "prompt": { + "type": "textarea", + "label": "Prompt", + "help": "Edits apply to future runs only.", + "required": false + }, + "model": { "type": "llm-profile", "label": "LLM profile", "required": false }, + "timeout": { + "type": "number", + "label": "Timeout (seconds)", + "help": "Maximum time a single run may take. Leave empty for the default of 600 seconds (10 minutes); maximum 1800 seconds (30 minutes).", + "required": false, + "constraints": { "min": 1, "max": 1800 } + }, + "schedule": { "type": "schedule", "label": "Frequency", "required": false } }, "importExport": { "fileKind": "automation", diff --git a/automations/interface.schema.json b/automations/interface.schema.json index 881a2df6..b44c28d6 100644 --- a/automations/interface.schema.json +++ b/automations/interface.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/OpenHands/extensions/automations/interface.schema.json", "title": "AutomationInterfaceManifest", - "description": "Strict schema for automations/interface.json. Mirrors the AutomationInterfaceManifest types in automations/index.d.ts. The catalog states what varies per automation; this file states the domain-level facts of the production Automation interface - routes, navigation, page-identity copy, the edit form, the import/export envelope, the service-relative endpoints, and the featured and responder id lists. The host renders it; it is data, not code - no markup in copy, no free-form URL, no host name, method, or credential.", + "description": "Strict schema for automations/interface.json. Mirrors the AutomationInterfaceManifest types in automations/index.d.ts. The catalog states what varies per automation; this file states the domain-level facts of the production Automation interface - routes, navigation, page-identity copy, the settable attributes of an automation, the import/export envelope, the service-relative endpoints, and the featured and responder id lists. The host renders it; it is data, not code - no markup in copy, no free-form URL, no host name, method, or credential.", "type": "object", "additionalProperties": false, "required": [ @@ -11,7 +11,7 @@ "navigation", "pages", "docsUrl", - "edit", + "attributes", "importExport", "endpoints", "featuredAutomationIds", @@ -60,7 +60,7 @@ "description": "Page-identity copy. Generic chrome - buttons, toasts, empty states, validation sentences - stays host copy, rendered through the host's translations.", "type": "object", "additionalProperties": false, - "required": ["list", "detail"], + "required": ["list", "detail", "edit"], "properties": { "list": { "type": "object", @@ -76,6 +76,12 @@ "additionalProperties": false, "required": ["backLabel"], "properties": { "backLabel": { "$ref": "#/$defs/copy" } } + }, + "edit": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { "title": { "$ref": "#/$defs/copy" } } } } }, @@ -84,25 +90,17 @@ "type": "string", "pattern": "^https://docs\\.openhands\\.dev/[A-Za-z0-9/._-]*$" }, - "edit": { - "description": "The edit dialog: which Automation properties it offers, keyed by the property each field edits. The closed key set is the editable surface of the runtime model; schedule is a semantic type whose frequency/weekday/time composite the host owns, like the setup form's cron type.", + "attributes": { + "description": "The input surface of an existing Automation: which attributes can be set after creation, keyed by the runtime-model property the host sends. The closed key set is the settable surface of the runtime model; how a client offers them - Agent Canvas renders an edit dialog, an agent sends them over the API - is the client's choice, not stated here. schedule is a semantic type whose frequency/weekday/time composite the host owns, like the setup form's cron type.", "type": "object", "additionalProperties": false, - "required": ["title", "fields"], + "minProperties": 1, "properties": { - "title": { "$ref": "#/$defs/copy" }, - "fields": { - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "name": { "$ref": "#/$defs/editField" }, - "prompt": { "$ref": "#/$defs/editField" }, - "model": { "$ref": "#/$defs/editField" }, - "timeout": { "$ref": "#/$defs/editField" }, - "schedule": { "$ref": "#/$defs/editField" } - } - } + "name": { "$ref": "#/$defs/attribute" }, + "prompt": { "$ref": "#/$defs/attribute" }, + "model": { "$ref": "#/$defs/attribute" }, + "timeout": { "$ref": "#/$defs/attribute" }, + "schedule": { "$ref": "#/$defs/attribute" } } }, "importExport": { @@ -203,7 +201,8 @@ { "pattern": "^[^{}]*\\{id\\}[^{}]*$" } ] }, - "editField": { + "attribute": { + "description": "How one settable attribute is offered: a semantic input type, user-visible copy, and value constraints. Only a number attribute carries constraints.", "type": "object", "additionalProperties": false, "required": ["type", "label", "required"], diff --git a/index.d.ts b/index.d.ts index d36dae6f..2c17c2ce 100644 --- a/index.d.ts +++ b/index.d.ts @@ -29,12 +29,12 @@ export type { AutomationTriggerForm, AutomationTriggerKind, RecommendedAutomation, - AutomationEditableProperty, - AutomationEditField, - AutomationEditFieldConstraints, - AutomationEditFieldType, + AutomationAttribute, + AutomationAttributeConstraints, + AutomationAttributeName, + AutomationAttributeType, AutomationImportDefaults, - AutomationInterfaceEdit, + AutomationInterfaceAttributes, AutomationInterfaceEndpoints, AutomationInterfaceImportExport, AutomationInterfaceManifest, diff --git a/tests/test_interface_manifest.py b/tests/test_interface_manifest.py index be3b0e97..2b60a23d 100644 --- a/tests/test_interface_manifest.py +++ b/tests/test_interface_manifest.py @@ -71,8 +71,8 @@ def test_featured_automations_resolve_to_catalog_entries(): lambda doc: doc.update(dashboards=[]), ), ( - "constraints on a non-number edit field", - lambda doc: doc["edit"]["fields"]["name"].update( + "constraints on a non-number attribute", + lambda doc: doc["attributes"]["name"].update( constraints={"max": 50} ), ),