mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 17:55:26 -04:00
Odysseus v1.0
This commit is contained in:
@@ -0,0 +1,910 @@
|
||||
"""CRUD routes for scheduled tasks."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.database import SessionLocal, ScheduledTask, TaskRun
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.task_scheduler import compute_next_run, HOUSEKEEPING_DEFAULTS
|
||||
from routes.prefs_routes import _load_for_user, _save_for_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
prompt: Optional[str] = None
|
||||
task_type: str = "llm" # "llm" | "action" | "research"
|
||||
action: Optional[str] = None # builtin action name
|
||||
schedule: Optional[str] = None # "once" | "daily" | "weekly" | "monthly" | "cron"
|
||||
scheduled_time: str = "09:00" # HH:MM
|
||||
scheduled_day: Optional[int] = None # day-of-week (0=Mon) or day-of-month
|
||||
scheduled_date: Optional[str] = None # ISO datetime for "once"
|
||||
cron_expression: Optional[str] = None # cron string e.g. "*/5 * * * *"
|
||||
trigger_type: str = "schedule" # "schedule" | "event" | "webhook"
|
||||
trigger_event: Optional[str] = None # e.g. "session_created"
|
||||
trigger_count: Optional[int] = None # fire every N events
|
||||
output_target: str = "session"
|
||||
model: Optional[str] = None
|
||||
endpoint_url: Optional[str] = None
|
||||
then_task_id: Optional[str] = None # chain: run this task after success
|
||||
notifications_enabled: Optional[bool] = None # None lets action-specific defaults apply
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
prompt: Optional[str] = None
|
||||
task_type: Optional[str] = None
|
||||
action: Optional[str] = None
|
||||
schedule: Optional[str] = None
|
||||
scheduled_time: Optional[str] = None
|
||||
scheduled_day: Optional[int] = None
|
||||
scheduled_date: Optional[str] = None
|
||||
cron_expression: Optional[str] = None
|
||||
trigger_type: Optional[str] = None
|
||||
trigger_event: Optional[str] = None
|
||||
trigger_count: Optional[int] = None
|
||||
output_target: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
endpoint_url: Optional[str] = None
|
||||
then_task_id: Optional[str] = None
|
||||
notifications_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
def _display_task_name(t: ScheduledTask) -> str:
|
||||
defs = HOUSEKEEPING_DEFAULTS.get(t.action) if t.action else None
|
||||
if defs and (t.name or "") in set(defs.get("legacy_names") or []):
|
||||
return defs["name"]
|
||||
return t.name
|
||||
|
||||
|
||||
def _task_to_dict(t: ScheduledTask, include_last_run_result: bool = False) -> dict:
|
||||
defs = HOUSEKEEPING_DEFAULTS.get(t.action) if t.action else None
|
||||
d = {
|
||||
"id": t.id,
|
||||
"name": _display_task_name(t),
|
||||
"prompt": t.prompt,
|
||||
"task_type": t.task_type or "llm",
|
||||
"action": t.action,
|
||||
"schedule": t.schedule,
|
||||
"scheduled_time": t.scheduled_time,
|
||||
"scheduled_day": t.scheduled_day,
|
||||
"scheduled_date": t.scheduled_date.isoformat() + "Z" if t.scheduled_date else None,
|
||||
"cron_expression": t.cron_expression,
|
||||
"trigger_type": t.trigger_type or "schedule",
|
||||
"trigger_event": t.trigger_event,
|
||||
"trigger_count": t.trigger_count,
|
||||
"trigger_counter": t.trigger_counter or 0,
|
||||
"next_run": t.next_run.isoformat() + "Z" if t.next_run else None,
|
||||
"last_run": t.last_run.isoformat() + "Z" if t.last_run else None,
|
||||
"status": t.status,
|
||||
"output_target": t.output_target,
|
||||
"session_id": t.session_id,
|
||||
"crew_member_id": getattr(t, "crew_member_id", None),
|
||||
"model": t.model,
|
||||
"endpoint_url": t.endpoint_url,
|
||||
"run_count": t.run_count or 0,
|
||||
"then_task_id": t.then_task_id,
|
||||
"notifications_enabled": bool(getattr(t, "notifications_enabled", True)),
|
||||
"webhook_token": t.webhook_token if (t.trigger_type or "schedule") == "webhook" else None,
|
||||
"created_at": t.created_at.isoformat() + "Z" if t.created_at else None,
|
||||
"updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
|
||||
}
|
||||
# Built-in housekeeping tasks (identified by their action) are flagged so
|
||||
# the UI can mark them and offer "revert to default" once altered.
|
||||
d["is_builtin"] = defs is not None
|
||||
if defs:
|
||||
default_names = {defs["name"], *set(defs.get("legacy_names") or [])}
|
||||
d["is_modified"] = (
|
||||
(t.name or "") not in default_names
|
||||
or (t.schedule or "") != (defs["schedule"] or "")
|
||||
or (t.scheduled_time or "") != (defs["scheduled_time"] or "")
|
||||
or (t.cron_expression or "") != (defs["cron_expression"] or "")
|
||||
)
|
||||
else:
|
||||
d["is_modified"] = False
|
||||
if include_last_run_result and t.runs:
|
||||
last = t.runs[0] # ordered desc by started_at
|
||||
d["last_run_status"] = last.status
|
||||
d["last_run_result"] = (last.result or last.error or "")[:500]
|
||||
return d
|
||||
|
||||
|
||||
def _run_to_dict(r: TaskRun) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"task_id": r.task_id,
|
||||
"started_at": r.started_at.isoformat() + "Z" if r.started_at else None,
|
||||
"finished_at": r.finished_at.isoformat() + "Z" if r.finished_at else None,
|
||||
"status": r.status,
|
||||
"result": r.result,
|
||||
"error": r.error,
|
||||
"tokens_used": r.tokens_used,
|
||||
"model": r.model,
|
||||
}
|
||||
|
||||
|
||||
def _run_research_id(task: ScheduledTask) -> str:
|
||||
if (task.task_type or "llm") == "research" and task.session_id:
|
||||
return task.session_id
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
||||
"""Best-effort endpoint URL for reopening a task run in chat."""
|
||||
if getattr(task, "endpoint_url", None):
|
||||
return task.endpoint_url or ""
|
||||
|
||||
try:
|
||||
if getattr(task, "session_id", None):
|
||||
from core.database import Session as DbSession
|
||||
sess = db.query(DbSession).filter(DbSession.id == task.session_id).first()
|
||||
if sess and sess.endpoint_url:
|
||||
return sess.endpoint_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
model = (getattr(run, "model", None) or getattr(task, "model", None) or "").strip()
|
||||
if not model:
|
||||
return ""
|
||||
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
for ep in eps:
|
||||
cached = []
|
||||
if ep.cached_models:
|
||||
try:
|
||||
cached = json.loads(ep.cached_models) or []
|
||||
except Exception:
|
||||
cached = []
|
||||
if model in cached:
|
||||
return ep.base_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
def _owner(request: Request):
|
||||
return get_current_user(request)
|
||||
|
||||
async def _generate_task_name(prompt: str) -> str:
|
||||
"""Use LLM to generate a short task name from the prompt."""
|
||||
try:
|
||||
from src.llm_core import llm_call_async
|
||||
from core.database import Session as DbSession
|
||||
db = SessionLocal()
|
||||
try:
|
||||
recent = db.query(DbSession).filter(
|
||||
DbSession.endpoint_url.isnot(None),
|
||||
DbSession.model.isnot(None),
|
||||
).order_by(DbSession.created_at.desc()).first()
|
||||
if not recent:
|
||||
return prompt[:50].strip()
|
||||
url, model = recent.endpoint_url, recent.model
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
result = await llm_call_async(
|
||||
url=url, model=model,
|
||||
messages=[
|
||||
{"role": "system", "content": "Generate a short title (3-5 words, no quotes) for this scheduled task. Reply with ONLY the title, nothing else."},
|
||||
{"role": "user", "content": prompt[:500]},
|
||||
],
|
||||
max_tokens=20,
|
||||
timeout=15,
|
||||
)
|
||||
title = result.strip().strip('"\'').strip()
|
||||
return title[:60] if title else prompt[:50].strip()
|
||||
except Exception:
|
||||
first = prompt.split('\n')[0].split('.')[0].strip()
|
||||
return first[:50] if first else "Untitled Task"
|
||||
|
||||
@router.get("")
|
||||
async def list_tasks(request: Request, status: Optional[str] = None,
|
||||
include_last_run: bool = False):
|
||||
user = _owner(request)
|
||||
if user:
|
||||
await task_scheduler.ensure_defaults(user)
|
||||
else:
|
||||
db_seed = SessionLocal()
|
||||
try:
|
||||
owners = {
|
||||
row[0] for row in db_seed.query(ScheduledTask.owner)
|
||||
.filter(ScheduledTask.task_type == "action")
|
||||
.filter(ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())))
|
||||
.all()
|
||||
if row[0]
|
||||
}
|
||||
finally:
|
||||
db_seed.close()
|
||||
for owner in owners:
|
||||
await task_scheduler.ensure_defaults(owner)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ScheduledTask)
|
||||
if user:
|
||||
q = q.filter(ScheduledTask.owner == user)
|
||||
if status:
|
||||
q = q.filter(ScheduledTask.status == status)
|
||||
tasks = q.order_by(ScheduledTask.created_at.desc()).all()
|
||||
return {"tasks": [_task_to_dict(t, include_last_run_result=include_last_run) for t in tasks]}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/onboarding")
|
||||
async def get_tasks_onboarding(request: Request):
|
||||
user = _owner(request)
|
||||
prefs = _load_for_user(user) or {}
|
||||
return {
|
||||
"opened": bool(prefs.get("tasks_opened")),
|
||||
"enabled": bool(prefs.get("tasks_enabled")),
|
||||
}
|
||||
|
||||
@router.post("/onboarding")
|
||||
async def update_tasks_onboarding(request: Request, body: dict):
|
||||
user = _owner(request)
|
||||
prefs = _load_for_user(user) or {}
|
||||
prefs["tasks_opened"] = True
|
||||
enable = bool(body.get("enabled"))
|
||||
if enable:
|
||||
prefs["tasks_enabled"] = True
|
||||
_save_for_user(user, prefs)
|
||||
if user:
|
||||
await task_scheduler.ensure_defaults(user)
|
||||
|
||||
resumed = 0
|
||||
if enable:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tasks = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == user,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())),
|
||||
).all()
|
||||
for task in tasks:
|
||||
defs = HOUSEKEEPING_DEFAULTS.get(task.action or "")
|
||||
if defs and defs.get("ship_paused"):
|
||||
continue
|
||||
if task.status == "active":
|
||||
continue
|
||||
task.status = "active"
|
||||
if (task.trigger_type or "schedule") == "schedule":
|
||||
task.next_run = compute_next_run(
|
||||
task.schedule,
|
||||
task.scheduled_time,
|
||||
task.scheduled_day,
|
||||
task.scheduled_date,
|
||||
cron_expression=task.cron_expression,
|
||||
)
|
||||
resumed += 1
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
return {"ok": True, "opened": True, "enabled": bool(prefs.get("tasks_enabled")), "resumed": resumed}
|
||||
|
||||
# Actions that execute shell/SSH commands — restricted to admins.
|
||||
# Non-admin users cannot create tasks with these action types via the
|
||||
# API. See review CRIT-C.
|
||||
_ADMIN_ONLY_ACTIONS = {"run_local", "run_script", "ssh_command"}
|
||||
|
||||
def _is_admin(user: str | None) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
# In-process tool-loopback marker — AuthMiddleware validated
|
||||
# the internal token + loopback client before stamping this,
|
||||
# so treat as admin-equivalent.
|
||||
if user == "internal-tool":
|
||||
return True
|
||||
try:
|
||||
from core.auth import AuthManager
|
||||
auth = AuthManager()
|
||||
if not auth.is_configured:
|
||||
# Unconfigured single-user deploy: trust the local owner.
|
||||
return True
|
||||
return bool(auth.is_admin(user))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@router.post("")
|
||||
async def create_task(request: Request, req: TaskCreate):
|
||||
user = _owner(request)
|
||||
|
||||
# Validate
|
||||
if req.task_type in ("llm", "research") and not req.prompt:
|
||||
raise HTTPException(400, "Prompt is required for LLM/research tasks")
|
||||
if req.task_type == "action" and not req.action:
|
||||
raise HTTPException(400, "Action name is required for action tasks")
|
||||
# Block shell-executing action types for non-admins. action_run_local
|
||||
# uses subprocess.run(shell=True) and ssh_command / run_script run
|
||||
# arbitrary commands.
|
||||
if req.task_type == "action" and req.action in _ADMIN_ONLY_ACTIONS and not _is_admin(user):
|
||||
raise HTTPException(403, f"Action '{req.action}' requires admin privileges")
|
||||
if req.trigger_type == "schedule" and not req.schedule:
|
||||
raise HTTPException(400, "Schedule is required for schedule-triggered tasks")
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and not req.cron_expression:
|
||||
raise HTTPException(400, "Cron expression is required for cron schedule")
|
||||
if req.trigger_type == "schedule" and req.schedule == "cron" and req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
if req.trigger_type == "event" and not req.trigger_event:
|
||||
raise HTTPException(400, "Event name is required for event-triggered tasks")
|
||||
if req.trigger_type == "event" and not req.trigger_count:
|
||||
raise HTTPException(400, "Trigger count is required for event-triggered tasks")
|
||||
|
||||
# Auto-generate name
|
||||
name = req.name
|
||||
if not name:
|
||||
if req.task_type == "action":
|
||||
from src.builtin_actions import BUILTIN_ACTION_INFO
|
||||
name = BUILTIN_ACTION_INFO.get(req.action, req.action or "Action Task")
|
||||
elif req.prompt:
|
||||
name = await _generate_task_name(req.prompt)
|
||||
else:
|
||||
name = "Untitled Task"
|
||||
|
||||
# Compute next_run for schedule-triggered tasks
|
||||
next_run = None
|
||||
sched_date = None
|
||||
if req.trigger_type == "schedule":
|
||||
if req.schedule == "once" and req.scheduled_date:
|
||||
try:
|
||||
sched_date = datetime.fromisoformat(req.scheduled_date.replace("Z", "+00:00")).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid scheduled_date format")
|
||||
next_run = compute_next_run(
|
||||
req.schedule, req.scheduled_time,
|
||||
req.scheduled_day, sched_date,
|
||||
cron_expression=req.cron_expression,
|
||||
)
|
||||
|
||||
# Generate webhook token if needed
|
||||
webhook_token = None
|
||||
if req.trigger_type == "webhook":
|
||||
webhook_token = secrets.token_urlsafe(32)
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
db = SessionLocal()
|
||||
try:
|
||||
notifications_enabled = (
|
||||
False if req.task_type == "action" and req.notifications_enabled is None
|
||||
else bool(req.notifications_enabled) if req.notifications_enabled is not None
|
||||
else True
|
||||
)
|
||||
task = ScheduledTask(
|
||||
id=task_id,
|
||||
owner=user,
|
||||
name=name,
|
||||
prompt=req.prompt,
|
||||
task_type=req.task_type,
|
||||
action=req.action,
|
||||
schedule=req.schedule,
|
||||
scheduled_time=req.scheduled_time,
|
||||
scheduled_day=req.scheduled_day,
|
||||
scheduled_date=sched_date,
|
||||
cron_expression=req.cron_expression,
|
||||
trigger_type=req.trigger_type,
|
||||
trigger_event=req.trigger_event,
|
||||
trigger_count=req.trigger_count,
|
||||
trigger_counter=0,
|
||||
next_run=next_run,
|
||||
status="active" if (req.trigger_type in ("event", "webhook") or next_run) else "completed",
|
||||
output_target=req.output_target,
|
||||
model=req.model or None,
|
||||
endpoint_url=req.endpoint_url or None,
|
||||
then_task_id=req.then_task_id or None,
|
||||
webhook_token=webhook_token,
|
||||
notifications_enabled=notifications_enabled,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return _task_to_dict(task)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/notifications")
|
||||
async def get_notifications(request: Request):
|
||||
"""Return and clear pending task-run notifications for the
|
||||
current user. Anonymous callers get nothing (prevents
|
||||
cross-tenant drain — see review CRIT-B)."""
|
||||
user = _owner(request)
|
||||
if not user:
|
||||
return {"notifications": []}
|
||||
notes = task_scheduler.pop_notifications(owner=user)
|
||||
return {"notifications": notes}
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def get_task(request: Request, task_id: str):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
return _task_to_dict(task)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.put("/{task_id}")
|
||||
async def update_task(request: Request, task_id: str, req: TaskUpdate):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
|
||||
if req.name is not None:
|
||||
task.name = req.name
|
||||
if req.prompt is not None:
|
||||
task.prompt = req.prompt
|
||||
if req.task_type is not None:
|
||||
task.task_type = req.task_type
|
||||
if req.action is not None:
|
||||
# Same admin-only gate as create — see CRIT-C.
|
||||
if req.action in _ADMIN_ONLY_ACTIONS and not _is_admin(user):
|
||||
raise HTTPException(403, f"Action '{req.action}' requires admin privileges")
|
||||
task.action = req.action
|
||||
if req.output_target is not None:
|
||||
task.output_target = req.output_target
|
||||
if req.model is not None:
|
||||
task.model = req.model or None
|
||||
if req.endpoint_url is not None:
|
||||
task.endpoint_url = req.endpoint_url or None
|
||||
if req.trigger_type is not None:
|
||||
# Generate webhook token when switching to webhook trigger
|
||||
if req.trigger_type == "webhook" and not task.webhook_token:
|
||||
task.webhook_token = secrets.token_urlsafe(32)
|
||||
task.trigger_type = req.trigger_type
|
||||
if req.trigger_event is not None:
|
||||
task.trigger_event = req.trigger_event
|
||||
if req.trigger_count is not None:
|
||||
task.trigger_count = req.trigger_count
|
||||
if req.then_task_id is not None:
|
||||
task.then_task_id = req.then_task_id or None
|
||||
if req.notifications_enabled is not None:
|
||||
task.notifications_enabled = bool(req.notifications_enabled)
|
||||
if req.cron_expression is not None:
|
||||
if req.cron_expression:
|
||||
try:
|
||||
from croniter import croniter
|
||||
croniter(req.cron_expression)
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid cron expression")
|
||||
task.cron_expression = req.cron_expression or None
|
||||
|
||||
# Recompute next_run if schedule changed
|
||||
schedule_changed = False
|
||||
if req.schedule is not None:
|
||||
task.schedule = req.schedule
|
||||
schedule_changed = True
|
||||
if req.scheduled_time is not None:
|
||||
task.scheduled_time = req.scheduled_time
|
||||
schedule_changed = True
|
||||
if req.scheduled_day is not None:
|
||||
task.scheduled_day = req.scheduled_day
|
||||
schedule_changed = True
|
||||
if req.scheduled_date is not None:
|
||||
try:
|
||||
task.scheduled_date = datetime.fromisoformat(
|
||||
req.scheduled_date.replace("Z", "+00:00")
|
||||
).replace(tzinfo=None)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid scheduled_date format")
|
||||
schedule_changed = True
|
||||
|
||||
if req.cron_expression is not None:
|
||||
schedule_changed = True
|
||||
|
||||
if schedule_changed and task.status == "active" and (task.trigger_type or "schedule") == "schedule":
|
||||
task.next_run = compute_next_run(
|
||||
task.schedule, task.scheduled_time,
|
||||
task.scheduled_day, task.scheduled_date,
|
||||
cron_expression=task.cron_expression,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return _task_to_dict(task)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def delete_task(request: Request, task_id: str):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/{task_id}/pause")
|
||||
async def pause_task(request: Request, task_id: str):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
task.status = "paused"
|
||||
db.commit()
|
||||
return {"ok": True, "status": "paused"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/{task_id}/resume")
|
||||
async def resume_task(request: Request, task_id: str):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
task.status = "active"
|
||||
if (task.trigger_type or "schedule") == "schedule":
|
||||
task.next_run = compute_next_run(
|
||||
task.schedule, task.scheduled_time,
|
||||
task.scheduled_day, task.scheduled_date,
|
||||
cron_expression=task.cron_expression,
|
||||
)
|
||||
db.commit()
|
||||
return {"ok": True, "status": "active", "next_run": task.next_run.isoformat() + "Z" if task.next_run else None}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/{task_id}/revert")
|
||||
async def revert_task(request: Request, task_id: str):
|
||||
"""Reset a built-in (housekeeping) task to its default config."""
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
defs = HOUSEKEEPING_DEFAULTS.get(task.action) if task.action else None
|
||||
if not defs:
|
||||
raise HTTPException(400, "Not a built-in task")
|
||||
task.name = defs["name"]
|
||||
task.schedule = defs["schedule"]
|
||||
task.scheduled_time = defs["scheduled_time"]
|
||||
task.scheduled_day = None
|
||||
task.scheduled_date = None
|
||||
task.cron_expression = defs["cron_expression"]
|
||||
task.trigger_type = defs.get("trigger_type", "schedule")
|
||||
task.trigger_event = defs.get("trigger_event")
|
||||
task.trigger_count = defs.get("trigger_count")
|
||||
task.trigger_counter = 0
|
||||
task.prompt = None
|
||||
task.model = None
|
||||
task.endpoint_url = None
|
||||
task.status = "paused" if defs.get("ship_paused") else "active"
|
||||
task.next_run = None
|
||||
if task.trigger_type == "schedule":
|
||||
task.next_run = compute_next_run(
|
||||
defs["schedule"], defs["scheduled_time"], None, None,
|
||||
cron_expression=defs["cron_expression"],
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return {"ok": True, "task": _task_to_dict(task)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/{task_id}/run")
|
||||
async def run_task_now(request: Request, task_id: str, force: bool = False):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
finally:
|
||||
db.close()
|
||||
started = await task_scheduler.run_task_now(task_id, force=force)
|
||||
if not started:
|
||||
raise HTTPException(409, "Task is already running")
|
||||
return {"ok": True, "message": "Task triggered" + (" in parallel" if force else "")}
|
||||
|
||||
@router.get("/runs/recent")
|
||||
async def list_recent_runs(request: Request, limit: int = 50):
|
||||
"""Recent task runs across ALL tasks for this owner. Drives the Activity view."""
|
||||
user = _owner(request)
|
||||
limit = max(1, min(limit, 200))
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(TaskRun, ScheduledTask).join(
|
||||
ScheduledTask, TaskRun.task_id == ScheduledTask.id
|
||||
)
|
||||
if user:
|
||||
# Strict owner scope — was previously OR'ing in `owner IS NULL`
|
||||
# rows for "legacy single-user" back-compat, but that leaks any
|
||||
# legacy/migrated task's full result text to every authenticated
|
||||
# user. _migrate_assign_legacy_owner runs on startup to claim
|
||||
# legacy rows for the admin, so the OR-NULL path is no longer
|
||||
# needed for any sane deploy.
|
||||
q = q.filter(ScheduledTask.owner == user)
|
||||
# Pull a little extra before de-duping. When auth is bypassed on a
|
||||
# local browser session, legacy/default tasks from multiple owners
|
||||
# can be visible together; the built-in urgent-email scanner then
|
||||
# produces several identical "no email accounts configured" rows in
|
||||
# the same minute. Keep the task records intact, but collapse those
|
||||
# duplicate Activity rows for display.
|
||||
rows = q.order_by(TaskRun.started_at.desc()).limit(limit * 3).all()
|
||||
deduped = []
|
||||
seen_urgency_rows = set()
|
||||
for r, t in rows:
|
||||
if (t.action or "") == "check_email_urgency":
|
||||
ts = r.started_at.replace(second=0, microsecond=0) if r.started_at else None
|
||||
text = (r.result or r.error or "").strip()
|
||||
key = (ts, r.status or "", text)
|
||||
if key in seen_urgency_rows:
|
||||
continue
|
||||
seen_urgency_rows.add(key)
|
||||
deduped.append((r, t))
|
||||
if len(deduped) >= limit:
|
||||
break
|
||||
return {
|
||||
"runs": [
|
||||
{
|
||||
**_run_to_dict(r),
|
||||
"task_name": _display_task_name(t),
|
||||
"task_type": t.task_type or "llm",
|
||||
"action": t.action,
|
||||
# Model + endpoint the task ran on, so the Activity
|
||||
# view's "Open in chat" can reuse the same model.
|
||||
"model": r.model or t.model or "",
|
||||
"endpoint_url": _resolve_run_endpoint(db, t, r),
|
||||
"session_id": t.session_id or "",
|
||||
"research_id": _run_research_id(t),
|
||||
# Where the task delivered its result — the Activity tab
|
||||
# uses this to filter notification rows in/out.
|
||||
"output_target": t.output_target or "session",
|
||||
}
|
||||
for r, t in deduped
|
||||
]
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/{task_id}/runs")
|
||||
async def list_runs(request: Request, task_id: str, limit: int = 20, offset: int = 0):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
runs = db.query(TaskRun).filter(TaskRun.task_id == task_id)\
|
||||
.order_by(TaskRun.started_at.desc())\
|
||||
.offset(offset).limit(limit).all()
|
||||
total = db.query(TaskRun).filter(TaskRun.task_id == task_id).count()
|
||||
return {"runs": [_run_to_dict(r) for r in runs], "total": total}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.get("/meta/output-targets")
|
||||
async def list_output_targets(request: Request):
|
||||
"""List available output targets — only delivery/send tools, not all MCP tools."""
|
||||
_owner(request)
|
||||
targets = [
|
||||
{"value": "session", "label": "Session", "description": "Save result to a chat session"},
|
||||
{"value": "notification", "label": "Notification", "description": "Push a browser notification with the result (also saved to the session for history)"},
|
||||
{"value": "email", "label": "Email me", "description": "Send result through your configured SMTP account"},
|
||||
]
|
||||
# Only include tools whose NAME clearly indicates an outbound delivery
|
||||
# action — match by verb in the tool name, not by any mention of "email"
|
||||
# in the description (which falsely picked up search_email, list_email,
|
||||
# etc.). Also exclude read/search/list tools whose names happen to start
|
||||
# with a delivery verb.
|
||||
_DELIVERY_VERBS = ("send", "notify", "post", "publish", "draft", "dispatch", "deliver")
|
||||
_NON_DELIVERY = (
|
||||
"search", "list", "get", "find", "read", "fetch", "view",
|
||||
"tag", "label", "move", "archive", "delete", "mark", "schedule",
|
||||
)
|
||||
try:
|
||||
from src.agent_tools import get_mcp_manager
|
||||
mcp = get_mcp_manager()
|
||||
if mcp:
|
||||
for tool in mcp.get_all_tools():
|
||||
name_lower = tool.get("name", "").lower()
|
||||
if any(x in name_lower for x in _NON_DELIVERY):
|
||||
continue
|
||||
if not any(v in name_lower for v in _DELIVERY_VERBS):
|
||||
continue
|
||||
targets.append({
|
||||
"value": tool["qualified_name"],
|
||||
"label": f"{tool['server_name']} → {tool['name']}",
|
||||
"description": tool.get("description", ""),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return {"targets": targets}
|
||||
|
||||
@router.get("/meta/actions")
|
||||
async def list_actions(request: Request):
|
||||
"""List available built-in actions."""
|
||||
user = _owner(request)
|
||||
from src.builtin_actions import BUILTIN_ACTION_INFO
|
||||
return {"actions": [
|
||||
{"name": name, "description": desc}
|
||||
for name, desc in BUILTIN_ACTION_INFO.items()
|
||||
if name not in _ADMIN_ONLY_ACTIONS or _is_admin(user)
|
||||
]}
|
||||
|
||||
@router.get("/meta/events")
|
||||
async def list_events(request: Request):
|
||||
"""List available event triggers."""
|
||||
_owner(request)
|
||||
return {"events": [
|
||||
{"name": "session_created", "description": "Fires when a new chat session is created"},
|
||||
{"name": "message_sent", "description": "Fires when a user sends a message"},
|
||||
{"name": "document_created", "description": "Fires when a document is created"},
|
||||
{"name": "memory_added", "description": "Fires when a memory is added"},
|
||||
{"name": "research_completed", "description": "Fires when a research report completes"},
|
||||
{"name": "email_received", "description": "Fires when new inbox mail is observed"},
|
||||
{"name": "skill_added", "description": "Fires when a new skill is created"},
|
||||
]}
|
||||
|
||||
@router.post("/{task_id}/webhook/{token}")
|
||||
async def webhook_trigger(task_id: str, token: str):
|
||||
"""Unauthenticated endpoint — the token IS the auth."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.id == task_id,
|
||||
ScheduledTask.webhook_token == token,
|
||||
ScheduledTask.status == "active",
|
||||
).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Not found")
|
||||
finally:
|
||||
db.close()
|
||||
started = await task_scheduler.run_task_now(task_id)
|
||||
if not started:
|
||||
raise HTTPException(409, "Task is already running")
|
||||
return {"ok": True, "message": "Task triggered via webhook"}
|
||||
|
||||
@router.post("/{task_id}/webhook-regenerate")
|
||||
async def regenerate_webhook(request: Request, task_id: str):
|
||||
user = _owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(404, "Task not found")
|
||||
if user and task.owner != user:
|
||||
raise HTTPException(403, "Access denied")
|
||||
task.webhook_token = secrets.token_urlsafe(32)
|
||||
db.commit()
|
||||
return {"ok": True, "webhook_token": task.webhook_token}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# --- PARSE NATURAL LANGUAGE → TASK DRAFT (AI) ---
|
||||
@router.post("/parse")
|
||||
async def parse_task(request: Request) -> Dict[str, Any]:
|
||||
"""Turn a free-form description ("every weekday at 7am research the top
|
||||
AI news and summarize it") into a structured task draft the frontend
|
||||
can pre-fill the form with. Returns a draft only — the user reviews and
|
||||
saves it, so a misread schedule never goes live unreviewed."""
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
from src.llm_core import llm_call_async
|
||||
from src.text_helpers import strip_think as _strip_think
|
||||
import json as _json, re as _re
|
||||
from datetime import datetime as _dt
|
||||
|
||||
body = await request.json()
|
||||
desc = (body.get("description") or "").strip()
|
||||
if not desc:
|
||||
return {"success": False, "message": "Nothing to parse"}
|
||||
|
||||
now = _dt.now()
|
||||
# Give the model the current date/time + weekday so relative phrasing
|
||||
# ("tomorrow", "every Monday", "in an hour") resolves correctly.
|
||||
ctx = now.strftime("%Y-%m-%d %H:%M (%A)")
|
||||
sys = (
|
||||
"You convert a user's description of a recurring or one-off task into "
|
||||
"STRICT JSON for a task scheduler. The current local date/time is "
|
||||
f"{ctx}. Output ONLY a JSON object, no prose, no markdown fences.\n\n"
|
||||
"Schema (omit fields you can't infer):\n"
|
||||
"{\n"
|
||||
' "task_type": "llm" | "research", // "research" if it asks to research/investigate/find out; else "llm"\n'
|
||||
' "name": "short 3-6 word title",\n'
|
||||
' "prompt": "the instruction the AI should run on schedule (or the research question)",\n'
|
||||
' "schedule": "daily" | "weekly" | "monthly" | "once" | "cron",\n'
|
||||
' "scheduled_time": "HH:MM", // 24h LOCAL time\n'
|
||||
' "scheduled_day": 0, // weekly: 0=Mon..6=Sun; monthly: 1..31\n'
|
||||
' "scheduled_date": "YYYY-MM-DDTHH:MM", // only for "once"\n'
|
||||
' "cron_expression": "m h dom mon dow", // only if schedule is "cron"\n'
|
||||
' "output_target": "session" | "email" | "notification" // use email when the user asks to email the result\n'
|
||||
"}\n\n"
|
||||
"Rules: default schedule to 'daily' if a time is given without a frequency. "
|
||||
"Default scheduled_time to '09:00' if none is stated. For 'every weekday' "
|
||||
"use cron '0 H * * 1-5'. Keep the prompt actionable and self-contained."
|
||||
)
|
||||
try:
|
||||
url, model, headers = resolve_endpoint("utility")
|
||||
if not url:
|
||||
url, model, headers = resolve_endpoint("default")
|
||||
if not (url and model):
|
||||
return {"success": False, "message": "No model endpoint configured"}
|
||||
raw = await llm_call_async(
|
||||
url=url, model=model,
|
||||
messages=[{"role": "system", "content": sys},
|
||||
{"role": "user", "content": desc[:1000]}],
|
||||
temperature=0.2, max_tokens=400, headers=headers, timeout=45,
|
||||
)
|
||||
text = _strip_think(raw or "", prose=False, prompt_echo=False).strip()
|
||||
if text.startswith("```"):
|
||||
text = text.strip("`")
|
||||
if text.lower().startswith("json"):
|
||||
text = text[4:].lstrip()
|
||||
# Pull the first {...} block in case the model added stray text.
|
||||
m = _re.search(r"\{.*\}", text, _re.S)
|
||||
draft = _json.loads(m.group(0) if m else text)
|
||||
if not isinstance(draft, dict):
|
||||
raise ValueError("not an object")
|
||||
# Whitelist + light validation so the frontend gets clean fields.
|
||||
out: Dict[str, Any] = {}
|
||||
if draft.get("task_type") in ("llm", "research"):
|
||||
out["task_type"] = draft["task_type"]
|
||||
else:
|
||||
out["task_type"] = "llm"
|
||||
for k in ("name", "prompt", "cron_expression", "scheduled_date"):
|
||||
if isinstance(draft.get(k), str) and draft[k].strip():
|
||||
out[k] = draft[k].strip()
|
||||
if draft.get("schedule") in ("daily", "weekly", "monthly", "once", "cron"):
|
||||
out["schedule"] = draft["schedule"]
|
||||
else:
|
||||
out["schedule"] = "daily"
|
||||
st = draft.get("scheduled_time")
|
||||
if isinstance(st, str) and _re.match(r"^\d{1,2}:\d{2}$", st.strip()):
|
||||
out["scheduled_time"] = st.strip()
|
||||
if isinstance(draft.get("scheduled_day"), int):
|
||||
out["scheduled_day"] = draft["scheduled_day"]
|
||||
if draft.get("output_target") in ("session", "email", "notification"):
|
||||
out["output_target"] = draft["output_target"]
|
||||
out["trigger_type"] = "schedule"
|
||||
if not out.get("prompt"):
|
||||
return {"success": False, "message": "Could not extract a task instruction"}
|
||||
return {"success": True, "draft": out}
|
||||
except Exception as e:
|
||||
logger.error(f"parse_task failed: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user