Files
simbarag/blueprints/cron/__init__.py
T
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:04 -04:00

209 lines
6.7 KiB
Python

import hmac
import logging
import os
from blueprints.scheduled_messages.validation import json_object
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,
"created_by": str(job.created_by_id) if job.created_by_id else None,
"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, error = await json_object(
("name", "prompt", "recipient", "channel", "description", "created_by"),
nullable=("description", "created_by"),
)
if error:
return error
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
from quart_jwt_extended import get_jwt_identity
job = await CronJob.create(
created_by_id=get_jwt_identity(),
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, error = await json_object(
("name", "prompt", "recipient", "channel", "description", "created_by"),
nullable=("description", "created_by"),
)
if error:
return error
blank = [
f for f in ("name", "prompt", "recipient") if f in data and not data[f].strip()
]
if blank:
return jsonify({"error": f"{', '.join(blank)} cannot be empty"}), 400
if "created_by" in data:
from blueprints.users.models import User
owner = (
await User.get_or_none(id=data["created_by"])
if data["created_by"]
else None
)
if not owner:
return jsonify({"error": "created_by must identify an existing user"}), 400
job.created_by_id = owner.id
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
data = await request.get_json(silent=True)
if not isinstance(data, dict):
data = {}
run_id = request.headers.get("Idempotency-Key") or data.get("run_id")
if not isinstance(run_id, str) or not run_id.strip() or len(run_id) > 255:
return jsonify(
{"error": "run_id or Idempotency-Key is required (maximum 255 characters)"}
), 400
from .scheduler import execute_job
result = await execute_job(job, run_id)
return jsonify(result), 200 if result.get("ok") or result.get("skipped") else 409
@cron_blueprint.route("/<job_id>/deliveries", methods=["GET"])
@admin_required
async def delivery_history(job_id):
if not await CronJob.filter(id=job_id).exists():
return jsonify({"error": "Not found"}), 404
from blueprints.scheduled_messages.delivery import history
return jsonify(await history("cron", job_id))
@cron_blueprint.route("/<job_id>/retry", methods=["POST"])
@admin_required
async def retry_job(job_id):
job = await CronJob.get_or_none(id=job_id)
if not job:
return jsonify({"error": "Not found"}), 404
data = await request.get_json(silent=True)
if not isinstance(data, dict):
data = {}
run_id = data.get("run_id")
if not isinstance(run_id, str) or not run_id or len(run_id) > 255:
return jsonify({"error": "run_id is required"}), 400
from blueprints.scheduled_messages.models import JobDelivery
if not await JobDelivery.filter(
kind="cron", job_id=str(job.id), run_id=run_id, status="failed"
).exists():
return jsonify({"error": "Only safe failed deliveries can be retried"}), 409
from .scheduler import execute_job
result = await execute_job(job, run_id, retry=True)
return jsonify(result), 200 if result.get("ok") or result.get("skipped") else 409