911 lines
40 KiB
Python
911 lines
40 KiB
Python
"""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
|