feat(cron): Cron Recipes — parameterized automation templates across every surface

A 'recipe' is a one-place definition of an automation that every surface
renders natively. The slot schema (cron/recipe_catalog.py) is the single
source of truth; four renderers consume it, and all paths end at the same
cron.jobs.create_job — no second job engine.

Form where there's a screen, conversation where there's a chat line:
- Dashboard / GUI app: a Recipes sub-tab on the Cron page renders each
  recipe's typed slots as a form (time-picker, enum dropdown, free-text);
  submit POSTs /api/cron/recipes/instantiate which fills + creates the job.
- CLI / TUI / messengers: /cron-recipe lists the catalog, shows a recipe's
  fields, or fills + creates from a pasted 'key slot=val' command. The shared
  handler (hermes_cli/cron_recipe_cmd.py) names any missing/invalid slot so
  the agent can ask a targeted follow-up.
- Docs: a generated Cron Recipes catalog page (website, .mdx + React cards)
  shows each recipe with a copy-paste command and a 'Send to App' button.
- Desktop: a hermes:// URL scheme (Electron single-instance lock +
  setAsDefaultProtocolClient + open-url/second-instance) routes
  hermes://cron-recipe/<key>?slot=val into the chat composer pre-filled.

Typed slots (time/enum/text/weekdays) with defaults: users never type raw
cron — recipes parameterize time-of-day and weekday sets and translate to
cron expressions; a free-text 'schedule' slot is the full-flexibility escape
hatch. Consent-first throughout: nothing schedules without an explicit submit
or send.

Core:
- cron/recipe_catalog.py — CronRecipe + RecipeSlot, 5 curated recipes,
  recipe_form_schema / recipe_slash_command / recipe_deeplink /
  recipe_catalog_entry renderers, fill_recipe (validate + translate to
  create_job kwargs).
- hermes_cli/cron_recipe_cmd.py — shared /cron-recipe handler (CLI + TUI +
  gateway never drift). CommandDef + dispatch in commands.py / cli.py /
  gateway/run.py.

Dashboard: GET /api/cron/recipes + POST /api/cron/recipes/instantiate
(web_server.py), CronRecipes.tsx gallery+form, Segmented sub-tab on CronPage,
api.ts methods + types.

Desktop: hermes:// scheme end to end (main.cjs deep-link router + ready-queue,
preload onDeepLink/signalDeepLinkReady, global.d.ts types, desktop-controller
composer prefill, electron-builder protocols key).

Docs: extract-cron-recipes.py generator wired into prebuild.mjs,
cron-recipes-catalog.mdx + CronRecipesCatalog React component, sidebar entry.
Generated index json gitignored like skills.json.

Tests: 23 core (catalog/slots/schedule-resolution/validation/renderers/command
handler/generator) + 5 web_server endpoint tests. E2E verified end to end:
slot fill -> create_job -> persisted job with correct schedule/deliver/origin.
This commit is contained in:
teknium1 2026-06-07 18:49:09 -07:00 committed by Teknium
parent 9a09ea69fb
commit 1593ca5406
25 changed files with 1975 additions and 0 deletions

View file

@ -6778,6 +6778,53 @@ async def delete_cron_job(job_id: str, profile: Optional[str] = None):
return {"ok": True}
# ---------------------------------------------------------------------------
# Cron Recipes — parameterized automation templates. The dashboard renders the
# slot schema as a form; submitting instantiates a real cron job via the same
# create_job path. See cron/recipe_catalog.py for the single source of truth.
# ---------------------------------------------------------------------------
class CronRecipeInstantiate(BaseModel):
recipe: str # recipe key, e.g. "morning-brief"
values: Dict[str, Any] = {} # filled slot values from the form
@app.get("/api/cron/recipes")
async def list_cron_recipes():
"""Return the recipe catalog as form schemas for the dashboard gallery."""
try:
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
return {"recipes": [recipe_catalog_entry(r) for r in CATALOG]}
except Exception as e:
_log.exception("GET /api/cron/recipes failed")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/cron/recipes/instantiate")
async def instantiate_cron_recipe(body: CronRecipeInstantiate, profile: str = "default"):
"""Fill a recipe's slots and create the cron job (form-submit path)."""
try:
from cron.recipe_catalog import fill_recipe, get_recipe, RecipeFillError
recipe = get_recipe(body.recipe)
if recipe is None:
raise HTTPException(status_code=404, detail=f"Unknown recipe: {body.recipe}")
try:
spec = fill_recipe(recipe, body.values)
except RecipeFillError as exc:
# Field-level validation error — 422 so the form can show it inline.
raise HTTPException(status_code=422, detail=str(exc)) from exc
# Recipe-created jobs deliver to the dashboard's configured target by
# default; the form's deliver slot overrides via spec["deliver"].
spec.pop("origin", None)
return _call_cron_for_profile(profile, "create_job", **spec)
except HTTPException:
raise
except Exception as e:
_log.exception("POST /api/cron/recipes/instantiate failed")
raise HTTPException(status_code=400, detail=str(e))
# ---------------------------------------------------------------------------
# MCP server endpoints — list / add / remove / test.
#