fix(cron-recipes): pre-release hardening — honest cadences, strict slot names, surface-aware UX
Review fixes for the Cron Recipes stack before release: - hydration-move: */90 in the cron minute field silently wraps to hourly (croniter-verified) — 90/120-minute options never fired at their stated cadence. Replaced with an hour-field step (0 9-17/2 * * 1-5) and an interval_hours slot whose options (1/2/3h) all fire as labeled. - fill_recipe: reject unknown slot names. A typo'd 'tiem=07:15' used to silently create the job at the 08:00 default; now it 422s on the dashboard form and errors on the slash/deep-link paths with the valid slot list. - deliver slot: non-strict enum (options are suggestions, scheduler validates downstream) so slack/whatsapp/etc. users aren't locked out; GET /api/cron/recipes rewrites its options from cron_delivery_targets() so the dashboard form only offers configured platforms; help text no longer claims dashboard-created jobs deliver to 'the chat you set this up from' (the endpoint strips origin — they go to the home channel). - gateway: success/accept messages no longer point at /cron (cli_only); surface-aware hint instead. Conversational fill now sends the 'Setting up X — I'll ask you a couple of things…' ack before the agent turn, matching the CLI experience. - important-mail catalog entry: reference the urgency classifier by module path (python3 -m cron.scripts.classify_items) instead of baking an absolute host path into the job prompt — stale after relocation and nonexistent on remote terminal backends. cron/scripts is now a real package and ships in the wheel (pyproject packages.find). - export_recipe: interval schedules round-trip again — parse_schedule stores 'minutes' but the renderer only read 'seconds', so every interval job exported as the silent '0 9 * * *' fallback. - skills_hub install: say so when a recipe suggestion is dropped (latched dedup or pending cap) instead of printing nothing. Targeted tests: 58 cron/recipe + 261 web_server pass; E2E-validated all 14 recipes fill+parse, hydration cadences via croniter, typo rejection on slash + endpoint paths, surface-aware hints, and interval export round-trip.
This commit is contained in:
@@ -234,10 +234,20 @@ def _fmt_no_match(query: str) -> str:
|
||||
return msg
|
||||
|
||||
|
||||
def _manage_hint(surface: str) -> str:
|
||||
"""Post-create management hint. /cron is a CLI-only slash command; on
|
||||
gateway platforms the user manages jobs by asking the agent (cronjob tool)
|
||||
or from the dashboard."""
|
||||
if surface == "cli":
|
||||
return "Manage it with /cron."
|
||||
return "Ask me to list, pause, or remove it any time."
|
||||
|
||||
|
||||
def handle_cron_recipe_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
surface: str = "cli",
|
||||
) -> RecipeCommandResult:
|
||||
"""Dispatch a ``/cron-recipe`` invocation.
|
||||
|
||||
@@ -246,7 +256,9 @@ def handle_cron_recipe_command(
|
||||
command is fully handled and only ``text`` is shown.
|
||||
|
||||
``args`` is everything after ``/cron-recipe``. ``origin`` lets a directly
|
||||
created job deliver back to the chat it was set up from.
|
||||
created job deliver back to the chat it was set up from. ``surface``
|
||||
(``"cli"`` | ``"gateway"``) picks the right wording for follow-up hints —
|
||||
``/cron`` only exists on the CLI.
|
||||
"""
|
||||
try:
|
||||
from cron.recipe_catalog import fill_recipe, RecipeFillError
|
||||
@@ -302,5 +314,5 @@ def handle_cron_recipe_command(
|
||||
return RecipeCommandResult(
|
||||
f"Scheduled '{recipe.title}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ f", delivering to {spec.get('deliver', 'origin')}. Manage it with /cron."
|
||||
+ f", delivering to {spec.get('deliver', 'origin')}. {_manage_hint(surface)}"
|
||||
)
|
||||
|
||||
@@ -715,6 +715,20 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
"[dim]Added to your suggestions — run[/] [bold]/suggestions[/] "
|
||||
"[dim]to schedule or dismiss it.[/]\n"
|
||||
)
|
||||
else:
|
||||
# Dropped: already offered/dismissed (latched) or the pending
|
||||
# list is at its cap. Say so instead of silently doing nothing —
|
||||
# the user can still schedule it by hand.
|
||||
c.print(
|
||||
f"[bold cyan]Recipe:[/] '{bundle.name}' is an automation "
|
||||
f"(schedule [bold]{spec.schedule}[/]), but it wasn't added to "
|
||||
"your suggestions (already offered/dismissed, or the pending "
|
||||
"list is full — run [bold]/suggestions[/] to review)."
|
||||
)
|
||||
c.print(
|
||||
"[dim]You can still schedule it any time by asking the agent "
|
||||
"or via[/] [bold]hermes cron add[/][dim].[/]\n"
|
||||
)
|
||||
except Exception: # pragma: no cover - recipe detection is best-effort
|
||||
pass
|
||||
|
||||
|
||||
@@ -67,13 +67,16 @@ def handle_suggestions_command(
|
||||
args: str,
|
||||
*,
|
||||
origin: Optional[Dict[str, Any]] = None,
|
||||
surface: str = "cli",
|
||||
) -> str:
|
||||
"""Dispatch a ``/suggestions`` invocation. Returns text to show the user.
|
||||
|
||||
``args`` is everything after ``/suggestions`` (already stripped of the
|
||||
command word). ``origin`` is the platform/chat dict so an accepted job's
|
||||
"origin" delivery routes back to where the user accepted; when omitted it
|
||||
is resolved from the session environment.
|
||||
is resolved from the session environment. ``surface`` (``"cli"`` |
|
||||
``"gateway"``) picks the wording for follow-up hints — ``/cron`` only
|
||||
exists on the CLI.
|
||||
"""
|
||||
if origin is None:
|
||||
origin = _resolve_origin()
|
||||
@@ -99,10 +102,15 @@ def handle_suggestions_command(
|
||||
return f"No pending suggestion matches '{rest}'. Run /suggestions to list them."
|
||||
sched = job.get("schedule_display") or (job.get("job_spec", {}) or {}).get("schedule", "")
|
||||
name = job.get("name", "automation")
|
||||
manage = (
|
||||
"Manage it with /cron."
|
||||
if surface == "cli"
|
||||
else "Ask me to list, pause, or remove it any time."
|
||||
)
|
||||
return (
|
||||
f"Scheduled '{name}'"
|
||||
+ (f" ({sched})" if sched else "")
|
||||
+ ". Manage it with /cron."
|
||||
+ f". {manage}"
|
||||
)
|
||||
|
||||
if sub in ("dismiss", "no", "reject"):
|
||||
|
||||
@@ -6790,11 +6790,33 @@ class CronRecipeInstantiate(BaseModel):
|
||||
|
||||
@app.get("/api/cron/recipes")
|
||||
async def list_cron_recipes():
|
||||
"""Return the recipe catalog as form schemas for the dashboard gallery."""
|
||||
"""Return the recipe catalog as form schemas for the dashboard gallery.
|
||||
|
||||
The ``deliver`` slot's options are rewritten from the user's actually
|
||||
configured gateway platforms (plus the universal origin/local/all), so the
|
||||
form never offers a platform that isn't connected.
|
||||
"""
|
||||
try:
|
||||
from cron.recipe_catalog import CATALOG, recipe_catalog_entry
|
||||
|
||||
return {"recipes": [recipe_catalog_entry(r) for r in CATALOG]}
|
||||
deliver_options = None
|
||||
try:
|
||||
from cron.scheduler import cron_delivery_targets
|
||||
|
||||
platforms = [t["id"] for t in cron_delivery_targets() if t.get("id")]
|
||||
deliver_options = ["origin", "local", *platforms]
|
||||
except Exception:
|
||||
_log.debug("cron_delivery_targets unavailable; using static deliver options", exc_info=True)
|
||||
|
||||
entries = []
|
||||
for r in CATALOG:
|
||||
entry = recipe_catalog_entry(r)
|
||||
if deliver_options:
|
||||
for f in entry.get("fields", []):
|
||||
if f.get("name") == "deliver":
|
||||
f["options"] = deliver_options
|
||||
entries.append(entry)
|
||||
return {"recipes": entries}
|
||||
except Exception as e:
|
||||
_log.exception("GET /api/cron/recipes failed")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user