Plugins can now call ctx.register_automation_blueprint(key, *, title, description, schedule_template, prompt_template, category, slots, deliver_default, skills, tags). The fields mirror the built-in AutomationBlueprint, and slots are BlueprintSlot field dicts, so the call also crosses the plugin-host boundary. - One lookup, cron.blueprint_catalog.list_blueprints() / get_blueprint(), merges the built-ins with the active profile's plugin blueprints at call time. /blueprint (CLI/TUI/messengers), /api/cron/blueprints and the instantiate route all use it. Each dashboard route resolves inside the ?profile= scope, off the event loop. - Keys are namespaced <plugin>:<key>, so a plugin cannot shadow a built-in. A ':' in the key, a duplicate key, an unknown placeholder, a bad slot type or a schedule that does not parse is warned and ignored at registration. - Catalog entries carry source/plugin. Desktop fetches the catalog for the profile it creates jobs in, keyed per profile, and shows the plugin name on the blueprint row and in the "Start from" select. The dashboard shows a plugin badge. - A created job is an ordinary cron job with the prompt and schedule already filled in, so it keeps running if the plugin is later disabled. - The ctx content registrars (register_skill, now joined by register_automation_blueprint) move to hermes_cli/plugins_content.py. The dashboard's blueprint types move to web/src/lib/automation-blueprints.ts. Closes #132373. Requested by @enckequity.
203 lines
8.4 KiB
Python
203 lines
8.4 KiB
Python
"""Plugins register Automation Blueprints via ``ctx.register_automation_blueprint``.
|
|
|
|
Contracts: a registered blueprint is listed next to the built-ins (``/blueprint`` and
|
|
``/api/cron/blueprints``) under a ``<plugin>:<key>`` key with a plugin source, instantiates
|
|
into a normal job, can never shadow a built-in, and is visible only in the profile whose
|
|
plugin registered it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import textwrap
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.web_models as _web_models
|
|
import hermes_cli.web_routers.cron as _rt_cron
|
|
|
|
_STANDUP = textwrap.dedent(
|
|
"""
|
|
def register(ctx):
|
|
ctx.register_automation_blueprint(
|
|
"standup",
|
|
title="Team standup digest",
|
|
description="Weekday summary of what changed in a repo.",
|
|
category="work",
|
|
schedule_template="{minute} {hour} * * 1-5",
|
|
prompt_template="Summarize yesterday's commits for {repo}.",
|
|
slots=[
|
|
{"name": "repo", "type": "text", "label": "Which repo?", "default": "acme/app"},
|
|
{"name": "time", "type": "time", "label": "What time?", "default": "09:15"},
|
|
{"name": "deliver", "type": "enum", "label": "Where?", "default": "origin",
|
|
"options": ["origin", "local"], "strict": False},
|
|
],
|
|
tags=["work"],
|
|
)
|
|
"""
|
|
)
|
|
|
|
|
|
def _write_plugin(home: Path, name: str, body: str, *, enable: bool = True) -> None:
|
|
plugin_dir = home / "plugins" / name
|
|
plugin_dir.mkdir(parents=True, exist_ok=True)
|
|
(plugin_dir / "plugin.yaml").write_text(f"name: {name}\nversion: 0.1.0\n")
|
|
(plugin_dir / "__init__.py").write_text(body)
|
|
# JSON is valid YAML; avoids a yaml import in the test module.
|
|
plugins = {"enabled": [name]} if enable else {"enabled": [], "disabled": [name]}
|
|
(home / "config.yaml").write_text(json.dumps({"model": "test-model", "plugins": plugins}))
|
|
|
|
|
|
@pytest.fixture()
|
|
def homes(tmp_path, monkeypatch):
|
|
"""Default profile home (active) plus a second profile home, both isolated."""
|
|
from hermes_cli import profiles
|
|
|
|
default_home = tmp_path / ".hermes"
|
|
profiles_root = default_home / "profiles"
|
|
other_home = profiles_root / "other"
|
|
for home in (default_home, other_home):
|
|
(home / "cron").mkdir(parents=True, exist_ok=True)
|
|
(home / "config.yaml").write_text("model: test-model\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home)
|
|
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root)
|
|
return {"default": default_home, "other": other_home}
|
|
|
|
|
|
def _plugin_keys(entries):
|
|
return {e["key"]: e for e in entries if e.get("source") == "plugin"}
|
|
|
|
|
|
def test_plugin_blueprint_is_listed_by_slash_command_and_matches(homes):
|
|
from hermes_cli.blueprint_cmd import handle_blueprint_command, match_blueprint
|
|
|
|
_write_plugin(homes["default"], "teamtools", _STANDUP)
|
|
|
|
catalog = handle_blueprint_command("")
|
|
assert "teamtools:standup" in catalog.text
|
|
assert "Team standup digest" in catalog.text
|
|
assert match_blueprint("teamtools:standup")[0].key == "teamtools:standup"
|
|
assert match_blueprint("standup")[0].key == "teamtools:standup"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plugin_blueprint_in_api_catalog_and_instantiates_real_job(homes):
|
|
from cron.jobs import load_jobs
|
|
|
|
_write_plugin(homes["default"], "teamtools", _STANDUP)
|
|
|
|
listed = await _rt_cron.list_cron_blueprints(profile="default")
|
|
entry = _plugin_keys(listed["blueprints"])["teamtools:standup"]
|
|
assert entry["plugin"] == "teamtools"
|
|
assert entry["title"] == "Team standup digest"
|
|
assert [f["name"] for f in entry["fields"]] == ["repo", "time", "deliver"]
|
|
builtin = next(e for e in listed["blueprints"] if e["key"] == "morning-brief")
|
|
assert builtin["source"] == "builtin" and builtin["plugin"] is None
|
|
|
|
created = await _rt_cron.instantiate_blueprint(
|
|
_web_models.AutomationBlueprintInstantiate(
|
|
blueprint="teamtools:standup",
|
|
values={"repo": "acme/widgets", "time": "10:05", "deliver": "local"},
|
|
),
|
|
profile="default",
|
|
)
|
|
assert created["name"] == "Team standup digest"
|
|
assert created["profile"] == "default"
|
|
[job] = [j for j in load_jobs() if j["id"] == created["id"]]
|
|
assert job["prompt"] == "Summarize yesterday's commits for acme/widgets."
|
|
assert job["schedule"]["expr"] == "5 10 * * 1-5"
|
|
assert job["deliver"] == "local"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plugin_blueprints_are_profile_scoped(homes):
|
|
_write_plugin(homes["default"], "teamtools", _STANDUP)
|
|
|
|
# Interleave A -> B -> A: no cached plugin catalog may leak across profiles.
|
|
a1 = _plugin_keys((await _rt_cron.list_cron_blueprints(profile="default"))["blueprints"])
|
|
b = _plugin_keys((await _rt_cron.list_cron_blueprints(profile="other"))["blueprints"])
|
|
a2 = _plugin_keys((await _rt_cron.list_cron_blueprints(profile="default"))["blueprints"])
|
|
assert set(a1) == set(a2) == {"teamtools:standup"}
|
|
assert b == {}
|
|
|
|
with pytest.raises(_rt_cron.HTTPException) as exc:
|
|
await _rt_cron.instantiate_blueprint(
|
|
_web_models.AutomationBlueprintInstantiate(blueprint="teamtools:standup", values={}),
|
|
profile="other",
|
|
)
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
def test_plugin_cannot_shadow_builtin_and_bad_keys_are_rejected(homes, caplog):
|
|
from cron.blueprint_catalog import get_blueprint, list_blueprints
|
|
|
|
body = textwrap.dedent(
|
|
"""
|
|
def register(ctx):
|
|
common = dict(title="Mine", description="d", schedule_template="0 9 * * *",
|
|
prompt_template="hi")
|
|
ctx.register_automation_blueprint("morning-brief", **common)
|
|
ctx.register_automation_blueprint("morning-brief", **common) # duplicate
|
|
ctx.register_automation_blueprint("other:spoof", **common) # namespace spoof
|
|
"""
|
|
)
|
|
_write_plugin(homes["default"], "teamtools", body)
|
|
|
|
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
|
keys = [bp.key for bp in list_blueprints()]
|
|
assert keys.count("morning-brief") == 1
|
|
builtin, mine = get_blueprint("morning-brief"), get_blueprint("teamtools:morning-brief")
|
|
assert builtin is not None and builtin.plugin == "" # still the built-in
|
|
assert mine is not None and mine.title == "Mine"
|
|
assert keys.count("teamtools:morning-brief") == 1
|
|
assert not any(k.endswith("spoof") for k in keys)
|
|
assert "already registered" in caplog.text
|
|
assert "other:spoof" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"fields, reason",
|
|
[
|
|
({"prompt_template": "hello {nobody}"}, "nobody"),
|
|
({"slots": [{"name": "x", "type": "colour", "label": "X"}]}, "type"),
|
|
({"schedule_template": "not a schedule"}, "schedule"),
|
|
({"schedule_template": "{minute} {hour} * * *"}, "time"),
|
|
({"title": ""}, "title"),
|
|
],
|
|
)
|
|
def test_malformed_plugin_blueprint_is_warned_and_ignored(homes, caplog, fields, reason):
|
|
from cron.blueprint_catalog import list_blueprints
|
|
|
|
base = dict(title="T", description="d", schedule_template="0 9 * * *", prompt_template="hi")
|
|
base.update(fields)
|
|
_write_plugin(homes["default"], "teamtools", f"def register(ctx):\n ctx.register_automation_blueprint('bad', **{base!r})\n")
|
|
|
|
with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"):
|
|
keys = [bp.key for bp in list_blueprints()]
|
|
assert "teamtools:bad" not in keys
|
|
assert "teamtools" in caplog.text and reason in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_job_from_plugin_blueprint_survives_plugin_disable(homes):
|
|
from cron.jobs import load_jobs
|
|
from hermes_cli.plugins import _reset_plugin_managers_for_tests
|
|
|
|
_write_plugin(homes["default"], "teamtools", _STANDUP)
|
|
created = await _rt_cron.instantiate_blueprint(
|
|
_web_models.AutomationBlueprintInstantiate(blueprint="teamtools:standup", values={}),
|
|
profile="default",
|
|
)
|
|
|
|
_write_plugin(homes["default"], "teamtools", _STANDUP, enable=False)
|
|
_reset_plugin_managers_for_tests()
|
|
|
|
listed = await _rt_cron.list_cron_blueprints(profile="default")
|
|
assert _plugin_keys(listed["blueprints"]) == {}
|
|
[job] = [j for j in load_jobs() if j["id"] == created["id"]]
|
|
assert job["prompt"] == "Summarize yesterday's commits for acme/app."
|
|
assert job["schedule"]["expr"] == "15 9 * * 1-5"
|