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:
Teknium
2026-06-11 10:49:47 -07:00
parent e976faac7a
commit e8b757845d
14 changed files with 185 additions and 28 deletions
+28 -1
View File
@@ -80,7 +80,34 @@ class TestValidation:
def test_bad_enum_rejected_and_names_slot(self):
with pytest.raises(RecipeFillError, match="not allowed"):
fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "pigeon"})
fill_recipe(get_recipe("news-digest"), {"count": "42"})
def test_deliver_slot_accepts_any_platform(self):
# deliver is a non-strict enum: its options are suggestions, the real
# set of valid platforms depends on the user's configured gateways and
# is validated downstream by the cron scheduler.
spec = fill_recipe(get_recipe("morning-brief"), {"time": "08:00", "deliver": "slack"})
assert spec["deliver"] == "slack"
def test_unknown_slot_name_rejected(self):
# A typo'd slot must NOT silently create a job with the default value.
with pytest.raises(RecipeFillError, match="unknown slot"):
fill_recipe(get_recipe("morning-brief"), {"tiem": "07:15"})
def test_hydration_hourly_step_actually_fires_at_chosen_cadence(self):
# Regression: a minute-field step (*/90) silently wraps to hourly.
# The hour-field step form must produce the cadence the user picked.
croniter = pytest.importorskip("croniter").croniter
from datetime import datetime
spec = fill_recipe(get_recipe("hydration-move"), {"interval_hours": "2"})
it = croniter(spec["schedule"], datetime(2026, 6, 10, 8, 0))
first_three = [it.get_next(datetime) for _ in range(3)]
gaps = {
(b - a).total_seconds()
for a, b in zip(first_three, first_three[1:])
}
assert gaps == {7200.0}, f"expected 2h gaps, got {spec['schedule']} -> {first_three}"
def test_text_slot_renders_into_prompt(self):
spec = fill_recipe(
+6 -1
View File
@@ -127,7 +127,12 @@ class TestCatalog:
from cron.suggestion_catalog import CATALOG, classify_items_script_path
monitor = next(e for e in CATALOG if e.key == "catalog:important-mail-monitor")
assert classify_items_script_path() in monitor.job_spec["prompt"]
# The prompt must reference the classifier by module path (resolvable
# at run time on any backend), never by a baked-in absolute path —
# absolute paths go stale after relocation and don't exist on remote
# terminal backends (Docker/Modal).
assert "cron.scripts.classify_items" in monitor.job_spec["prompt"]
assert classify_items_script_path() not in monitor.job_spec["prompt"]
assert Path(classify_items_script_path()).name == "classify_items.py"
+19
View File
@@ -167,3 +167,22 @@ class TestExportRecipe:
md = export_recipe(job, "body")
assert "recipe" in md
assert "automation" in md
def test_export_interval_job_without_display(self):
# Regression: parse_schedule stores interval periods as "minutes" —
# exporting a job with only the parsed schedule dict must round-trip
# the real interval, not fall back to the daily default.
job = {
"name": "poller",
"schedule": {"kind": "interval", "minutes": 30},
"skills": ["poller"],
}
md = export_recipe(job, "body")
spec = parse_recipe(md)
assert spec is not None
assert spec.schedule == "every 30m"
job["schedule"] = {"kind": "interval", "minutes": 120}
spec = parse_recipe(export_recipe(job, "body"))
assert spec is not None
assert spec.schedule == "every 2h"