5809ad3adb
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>
209 lines
6.7 KiB
Python
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
|