Add Cronicle-driven cron jobs with local migration workflow

Adds a new cron blueprint with UUID-keyed jobs that Cronicle triggers via
HTTP POST with a shared secret header. Also adds scripts/migrate-new.sh so
migrations can be generated locally against a Docker postgres when no prod
container is running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 19:10:15 -04:00
parent 82f16bfd6a
commit bb4619aefe
8 changed files with 253 additions and 2 deletions
+5
View File
@@ -121,6 +121,11 @@ ALLOWED_IMESSAGE_NUMBERS=
# If GOOGLE_TOKEN_FILE points to a valid token, both Calendar and Gmail are enabled.
GOOGLE_TOKEN_FILE=/app/config/google-token.json
# Cronicle Integration
# Shared secret for Cronicle to authenticate cron job trigger requests.
# Generate with: openssl rand -hex 32
CRON_SECRET=
# SimbaKit / PetKit Configuration
# PetKit account credentials (used by SimbaKit to poll device data)
PETKIT_USERNAME=your-petkit-email
+1 -2
View File
@@ -55,8 +55,7 @@ migrate:
$(COMPOSE) exec $(RAGGR) aerich upgrade
migrate-new:
@read -p "Migration name: " name; \
$(COMPOSE) exec $(RAGGR) aerich migrate --name $$name
@./scripts/migrate-new.sh
migrate-history:
$(COMPOSE) exec $(RAGGR) aerich history
+2
View File
@@ -24,6 +24,7 @@ import blueprints.users
import blueprints.whatsapp
import blueprints.imessage
import blueprints.scheduled_messages
import blueprints.cron
import blueprints.users.models
from config.db import TORTOISE_CONFIG
from metrics import render_metrics
@@ -63,6 +64,7 @@ app.register_blueprint(blueprints.rag.rag_blueprint)
app.register_blueprint(blueprints.whatsapp.whatsapp_blueprint)
app.register_blueprint(blueprints.imessage.imessage_blueprint)
app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprint)
app.register_blueprint(blueprints.cron.cron_blueprint)
async def _obsidian_sync_loop():
+178
View File
@@ -0,0 +1,178 @@
import hmac
import logging
import os
from datetime import datetime, timezone
from quart import Blueprint, jsonify, request
from blueprints.users.decorators import admin_required
from blueprints.scheduled_messages.models import MessageChannel
from .models import CronJob
cron_blueprint = Blueprint("cron_api", __name__, url_prefix="/api/cron")
logger = logging.getLogger(__name__)
def _serialize(job: CronJob) -> dict:
return {
"id": str(job.id),
"name": job.name,
"description": job.description,
"prompt": job.prompt,
"recipient": job.recipient,
"channel": job.channel.value,
"enabled": job.enabled,
"last_run_at": job.last_run_at.isoformat() if job.last_run_at else None,
"last_status": job.last_status,
"last_error": job.last_error,
"created_at": job.created_at.isoformat(),
"updated_at": job.updated_at.isoformat(),
}
def _check_cron_secret() -> bool:
expected = os.environ.get("CRON_SECRET", "")
if not expected:
return False
provided = request.headers.get("X-Cron-Secret", "")
return hmac.compare_digest(provided.encode(), expected.encode())
@cron_blueprint.route("/", methods=["GET"])
@admin_required
async def list_jobs():
jobs = await CronJob.all().order_by("name")
return jsonify([_serialize(j) for j in jobs])
@cron_blueprint.route("/", methods=["POST"])
@admin_required
async def create_job():
data = await request.get_json()
if not data:
return jsonify({"error": "Invalid payload"}), 400
name = (data.get("name") or "").strip()
prompt = (data.get("prompt") or "").strip()
recipient = (data.get("recipient") or "").strip()
channel_str = data.get("channel")
description = (data.get("description") or "").strip() or None
if not name or not prompt or not recipient or not channel_str:
return jsonify(
{"error": "name, prompt, recipient, and channel are required"}
), 400
try:
channel = MessageChannel(channel_str)
except ValueError:
return jsonify(
{"error": f"Invalid channel: {channel_str}. Must be 'imessage' or 'email'"}
), 400
job = await CronJob.create(
name=name,
description=description,
prompt=prompt,
recipient=recipient,
channel=channel,
enabled=bool(data.get("enabled", True)),
)
return jsonify(_serialize(job)), 201
@cron_blueprint.route("/<job_id>", methods=["PUT"])
@admin_required
async def update_job(job_id: str):
job = await CronJob.get_or_none(id=job_id)
if not job:
return jsonify({"error": "Not found"}), 404
data = await request.get_json()
if not data:
return jsonify({"error": "Invalid payload"}), 400
if "name" in data:
job.name = data["name"].strip()
if "description" in data:
job.description = (data["description"] or "").strip() or None
if "prompt" in data:
job.prompt = data["prompt"].strip()
if "recipient" in data:
job.recipient = data["recipient"].strip()
if "channel" in data:
try:
job.channel = MessageChannel(data["channel"])
except ValueError:
return jsonify({"error": f"Invalid channel: {data['channel']}"}), 400
if "enabled" in data:
job.enabled = bool(data["enabled"])
await job.save()
return jsonify(_serialize(job))
@cron_blueprint.route("/<job_id>", methods=["DELETE"])
@admin_required
async def delete_job(job_id: str):
job = await CronJob.get_or_none(id=job_id)
if not job:
return jsonify({"error": "Not found"}), 404
await job.delete()
return jsonify({"status": "deleted"})
@cron_blueprint.route("/<job_id>/trigger", methods=["POST"])
async def trigger_job(job_id: str):
if not _check_cron_secret():
return jsonify({"error": "Unauthorized"}), 401
job = await CronJob.get_or_none(id=job_id)
if not job:
return jsonify({"error": "Not found"}), 404
if not job.enabled:
return jsonify({"skipped": True}), 200
try:
from blueprints.conversation.agents import main_agent
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
messages_payload = [
{"role": "system", "content": SIMBA_SYSTEM_PROMPT},
{"role": "user", "content": job.prompt},
]
response = await main_agent.ainvoke({"messages": messages_payload})
result = response.get("messages", [])[-1].content
if job.channel == MessageChannel.IMESSAGE:
from blueprints.imessage import send_imessage
from utils.strip_markdown import strip_markdown
await send_imessage(job.recipient, strip_markdown(result))
elif job.channel == MessageChannel.EMAIL:
from blueprints.email import send_email_reply
await send_email_reply(
to=job.recipient,
subject=job.name,
body=result,
)
job.last_run_at = datetime.now(timezone.utc)
job.last_status = "success"
job.last_error = None
await job.save()
logger.info(f"Cron job {job.id} ({job.name}) executed successfully")
return jsonify({"ok": True})
except Exception as e:
job.last_run_at = datetime.now(timezone.utc)
job.last_status = "failed"
job.last_error = str(e)
await job.save()
logger.error(f"Cron job {job.id} ({job.name}) failed: {e}")
return jsonify({"error": str(e)}), 500
+22
View File
@@ -0,0 +1,22 @@
from tortoise import fields
from tortoise.models import Model
from blueprints.scheduled_messages.models import MessageChannel
class CronJob(Model):
id = fields.UUIDField(primary_key=True)
name = fields.CharField(max_length=255)
description = fields.TextField(null=True)
prompt = fields.TextField()
recipient = fields.CharField(max_length=255)
channel = fields.CharEnumField(enum_type=MessageChannel, max_length=20)
enabled = fields.BooleanField(default=True)
last_run_at = fields.DatetimeField(null=True)
last_status = fields.CharField(max_length=50, null=True)
last_error = fields.TextField(null=True)
created_at = fields.DatetimeField(auto_now_add=True)
updated_at = fields.DatetimeField(auto_now=True)
class Meta:
table = "cron_jobs"
+1
View File
@@ -17,6 +17,7 @@ TORTOISE_CONFIG = {
"blueprints.users.models",
"blueprints.email.models",
"blueprints.scheduled_messages.models",
"blueprints.cron.models",
"aerich.models",
],
"default_connection": "default",
+1
View File
@@ -60,6 +60,7 @@ x-raggr-common: &raggr-common
- SENDBLUE_SIGNATURE_VALIDATION=${SENDBLUE_SIGNATURE_VALIDATION:-true}
- ALLOWED_IMESSAGE_NUMBERS=${ALLOWED_IMESSAGE_NUMBERS}
- SIMBAKIT_URL=${SIMBAKIT_URL:-http://simbakit:8585}
- CRON_SECRET=${CRON_SECRET}
depends_on:
postgres:
condition: service_healthy
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# Generate a new aerich migration.
# If a raggr container is running, exec into it.
# Otherwise, spin up the local postgres service and run aerich from the venv.
set -euo pipefail
cd "$(dirname "$0")/.."
# Load .env so POSTGRES_* vars are available locally
if [ -f .env ]; then
set -a
# shellcheck disable=SC1091
source .env
set +a
fi
POSTGRES_USER="${POSTGRES_USER:-raggr}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-changeme}"
POSTGRES_DB="${POSTGRES_DB:-raggr}"
LOCAL_DB_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}"
read -rp "Migration name: " name
RUNNING=$(docker compose ps --status running --services 2>/dev/null | grep -oE 'raggr-(blue|green)' | head -1 || true)
if [ -n "$RUNNING" ]; then
echo "==> Using running container: $RUNNING"
docker compose --profile blue --profile green exec "$RUNNING" aerich migrate --name "$name"
else
echo "==> No raggr container running — using local postgres"
docker compose up -d postgres
echo "==> Waiting for postgres to be ready..."
until docker compose exec -T postgres pg_isready -U "$POSTGRES_USER" > /dev/null 2>&1; do
sleep 1
done
echo "==> Applying existing migrations..."
DATABASE_URL="$LOCAL_DB_URL" .venv/bin/aerich upgrade
echo "==> Generating migration: $name"
DATABASE_URL="$LOCAL_DB_URL" .venv/bin/aerich migrate --name "$name"
fi