2026-03-31 23:36:01 -05:00
|
|
|
|
"""
|
2026-04-19 23:38:58 +09:00
|
|
|
|
Automation utilities and unified scheduler.
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
2026-04-19 23:38:58 +09:00
|
|
|
|
RRULE helpers, scheduler worker loop, and execution logic.
|
2026-03-31 23:36:01 -05:00
|
|
|
|
Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
|
|
|
|
|
|
|
2026-04-19 23:38:58 +09:00
|
|
|
|
The scheduler_worker_loop handles all time-based background work:
|
|
|
|
|
|
- Automation execution (claim_due → execute)
|
|
|
|
|
|
- Calendar event alerts (upcoming events → socket + webhook notifications)
|
|
|
|
|
|
|
2026-03-31 23:36:01 -05:00
|
|
|
|
Environment:
|
2026-04-19 23:38:58 +09:00
|
|
|
|
SCHEDULER_POLL_INTERVAL – seconds between polls (default: 10)
|
|
|
|
|
|
CALENDAR_ALERT_LOOKAHEAD_MINUTES – default alert window (default: 5)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
|
|
|
|
|
import random
|
|
|
|
|
|
import time
|
2026-06-29 00:05:10 -05:00
|
|
|
|
from datetime import datetime, timedelta
|
2026-03-31 23:36:01 -05:00
|
|
|
|
from typing import Optional
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
from zoneinfo import ZoneInfo
|
|
|
|
|
|
|
|
|
|
|
|
from dateutil.rrule import rrulestr
|
|
|
|
|
|
from fastapi import Request
|
2026-06-29 00:05:10 -05:00
|
|
|
|
from fastapi.security import HTTPAuthorizationCredentials
|
2026-04-13 14:08:58 -05:00
|
|
|
|
from open_webui.constants import ERROR_MESSAGES
|
2026-06-25 03:31:45 +01:00
|
|
|
|
from open_webui.events import EVENTS, publish_event
|
2026-05-12 17:10:15 +09:00
|
|
|
|
from open_webui.internal.db import get_async_db
|
|
|
|
|
|
from open_webui.models.automations import AutomationModel, AutomationRuns, Automations
|
2026-03-31 23:36:01 -05:00
|
|
|
|
from open_webui.models.chats import ChatForm, Chats
|
2026-06-17 02:52:35 +02:00
|
|
|
|
from open_webui.models.config import Config
|
2026-03-31 23:36:01 -05:00
|
|
|
|
from open_webui.models.users import Users
|
2026-06-29 00:05:10 -05:00
|
|
|
|
from open_webui.utils.auth import create_token
|
|
|
|
|
|
from open_webui.utils.misc import parse_duration
|
2026-03-31 23:36:01 -05:00
|
|
|
|
from open_webui.utils.task import prompt_template
|
2026-05-12 17:10:15 +09:00
|
|
|
|
from starlette.datastructures import Headers
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-04-19 23:38:58 +09:00
|
|
|
|
SCHEDULER_POLL_INTERVAL = int(os.getenv('SCHEDULER_POLL_INTERVAL', os.getenv('AUTOMATION_POLL_INTERVAL', '10')))
|
|
|
|
|
|
CALENDAR_ALERT_LOOKAHEAD_MINUTES = int(os.getenv('CALENDAR_ALERT_LOOKAHEAD_MINUTES', '10'))
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
####################
|
|
|
|
|
|
# RRULE Helpers
|
|
|
|
|
|
####################
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 13:51:39 +09:00
|
|
|
|
def _resolve_tz(tz: str = None) -> Optional[ZoneInfo]:
|
|
|
|
|
|
"""Safely resolve a timezone string to ZoneInfo.
|
|
|
|
|
|
|
|
|
|
|
|
Returns None (→ server-local fallback) when *tz* is empty, None,
|
|
|
|
|
|
or an unrecognised IANA key. Logs a warning on bad keys so
|
|
|
|
|
|
misconfiguration is visible in the server logs.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not tz:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return ZoneInfo(tz)
|
|
|
|
|
|
except (KeyError, Exception):
|
|
|
|
|
|
log.warning('Unknown timezone %r — falling back to server time', tz)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 00:11:11 -05:00
|
|
|
|
def _parse_rule(s: str):
|
|
|
|
|
|
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
|
|
|
|
|
|
|
|
|
|
|
|
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
|
|
|
|
|
|
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
|
|
|
|
|
|
"""
|
|
|
|
|
|
raw = s.replace('RRULE:', '')
|
|
|
|
|
|
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
|
|
|
|
|
|
freq = parts.get('FREQ', '')
|
|
|
|
|
|
|
|
|
|
|
|
if freq in ('MINUTELY', 'HOURLY'):
|
|
|
|
|
|
epoch = datetime(2000, 1, 1, 0, 0, 0)
|
|
|
|
|
|
return rrulestr(s, dtstart=epoch, ignoretz=True)
|
|
|
|
|
|
return rrulestr(s, ignoretz=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 13:46:39 +09:00
|
|
|
|
def validate_rrule(s: str, tz: str = None) -> None:
|
|
|
|
|
|
"""Raise ValueError if the RRULE is malformed or exhausted.
|
|
|
|
|
|
|
|
|
|
|
|
When *tz* is provided the "now" reference uses the user's local
|
|
|
|
|
|
clock so that near-future schedules are not incorrectly rejected
|
|
|
|
|
|
on servers whose system clock is ahead (e.g. UTC vs US timezones).
|
|
|
|
|
|
"""
|
2026-03-31 23:36:01 -05:00
|
|
|
|
try:
|
2026-04-01 00:11:11 -05:00
|
|
|
|
rule = _parse_rule(s)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
except Exception as e:
|
2026-04-13 14:08:58 -05:00
|
|
|
|
raise ValueError(ERROR_MESSAGES.AUTOMATION_INVALID_RRULE(e))
|
2026-04-21 13:51:39 +09:00
|
|
|
|
zi = _resolve_tz(tz)
|
|
|
|
|
|
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
2026-04-21 13:46:39 +09:00
|
|
|
|
if rule.after(now) is None:
|
2026-04-13 14:08:58 -05:00
|
|
|
|
raise ValueError(ERROR_MESSAGES.AUTOMATION_NO_FUTURE_RUNS)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def next_run_ns(s: str, tz: str = None) -> Optional[int]:
|
|
|
|
|
|
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
|
2026-04-21 13:51:39 +09:00
|
|
|
|
zi = _resolve_tz(tz)
|
|
|
|
|
|
now = datetime.now(zi) if zi else datetime.now()
|
2026-04-01 00:11:11 -05:00
|
|
|
|
dt = _parse_rule(s).after(now.replace(tzinfo=None))
|
2026-03-31 23:36:01 -05:00
|
|
|
|
if dt is None:
|
|
|
|
|
|
return None
|
2026-04-21 13:51:39 +09:00
|
|
|
|
if zi:
|
|
|
|
|
|
dt = dt.replace(tzinfo=zi)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
return int(dt.timestamp() * 1_000_000_000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
|
2026-04-21 13:46:39 +09:00
|
|
|
|
"""Compute next N occurrences for UI preview.
|
|
|
|
|
|
|
|
|
|
|
|
Uses the user's timezone for the starting "now" so that the
|
|
|
|
|
|
preview matches the user's local clock (same as next_run_ns).
|
|
|
|
|
|
"""
|
2026-04-21 13:51:39 +09:00
|
|
|
|
zi = _resolve_tz(tz)
|
2026-04-01 00:11:11 -05:00
|
|
|
|
rule = _parse_rule(s)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
result = []
|
2026-04-21 13:51:39 +09:00
|
|
|
|
now = datetime.now(zi).replace(tzinfo=None) if zi else datetime.now()
|
2026-04-21 13:46:39 +09:00
|
|
|
|
dt = now
|
2026-03-31 23:36:01 -05:00
|
|
|
|
for _ in range(n):
|
|
|
|
|
|
dt = rule.after(dt)
|
|
|
|
|
|
if not dt:
|
|
|
|
|
|
break
|
2026-04-21 13:51:39 +09:00
|
|
|
|
if zi:
|
|
|
|
|
|
dt_tz = dt.replace(tzinfo=zi)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
result.append(int(dt_tz.timestamp() * 1_000_000_000))
|
|
|
|
|
|
else:
|
|
|
|
|
|
result.append(int(dt.timestamp() * 1_000_000_000))
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 17:06:58 -06:00
|
|
|
|
def rrule_interval_seconds(s: str) -> Optional[int]:
|
|
|
|
|
|
"""Approximate interval between recurrences in seconds.
|
|
|
|
|
|
|
|
|
|
|
|
Returns None for one-shot (COUNT=1) schedules or rules
|
|
|
|
|
|
with fewer than two future occurrences.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if 'COUNT=1' in s:
|
|
|
|
|
|
return None
|
|
|
|
|
|
rule = _parse_rule(s)
|
|
|
|
|
|
now = datetime.now()
|
|
|
|
|
|
first = rule.after(now)
|
|
|
|
|
|
if first is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
second = rule.after(first)
|
|
|
|
|
|
if second is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return int((second - first).total_seconds())
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 23:36:01 -05:00
|
|
|
|
############################
|
|
|
|
|
|
# Worker Loop
|
|
|
|
|
|
############################
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 23:38:58 +09:00
|
|
|
|
# Keep the old name as an alias so any stale imports still work.
|
2026-03-31 23:36:01 -05:00
|
|
|
|
async def automation_worker_loop(app) -> None:
|
2026-04-19 23:38:58 +09:00
|
|
|
|
"""Deprecated alias — use scheduler_worker_loop."""
|
|
|
|
|
|
await scheduler_worker_loop(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def scheduler_worker_loop(app) -> None:
|
|
|
|
|
|
"""Unified background scheduler for all time-based work.
|
|
|
|
|
|
|
|
|
|
|
|
Handles:
|
|
|
|
|
|
1. Automation execution (ENABLE_AUTOMATIONS)
|
|
|
|
|
|
2. Calendar event alerts (ENABLE_CALENDAR)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
Runs on every instance. Poll interval is configurable via
|
2026-04-19 23:38:58 +09:00
|
|
|
|
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
|
2026-03-31 23:36:01 -05:00
|
|
|
|
"""
|
2026-04-19 23:38:58 +09:00
|
|
|
|
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
|
2026-03-31 23:36:01 -05:00
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
2026-04-19 23:38:58 +09:00
|
|
|
|
# ── Automations ──
|
2026-06-17 02:52:35 +02:00
|
|
|
|
if await Config.get('automations.enable'):
|
2026-04-19 23:38:58 +09:00
|
|
|
|
try:
|
|
|
|
|
|
async with get_async_db() as db:
|
|
|
|
|
|
batch = await Automations.claim_due(int(time.time_ns()), limit=10, db=db)
|
|
|
|
|
|
if batch:
|
|
|
|
|
|
log.info(f'Claimed {len(batch)} due automation(s)')
|
|
|
|
|
|
for automation in batch:
|
|
|
|
|
|
asyncio.create_task(execute_automation(app, automation))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
log.exception('Scheduler: automation error')
|
|
|
|
|
|
|
|
|
|
|
|
# ── Calendar Alerts ──
|
2026-06-17 02:52:35 +02:00
|
|
|
|
if await Config.get('calendar.enable'):
|
2026-04-19 23:38:58 +09:00
|
|
|
|
try:
|
|
|
|
|
|
await _check_calendar_alerts(app)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
log.exception('Scheduler: calendar alert error')
|
|
|
|
|
|
|
2026-03-31 23:36:01 -05:00
|
|
|
|
except Exception:
|
2026-04-19 23:38:58 +09:00
|
|
|
|
log.exception('Scheduler worker error')
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
# Jitter to spread load across instances
|
2026-04-19 23:38:58 +09:00
|
|
|
|
await asyncio.sleep(SCHEDULER_POLL_INTERVAL + random.uniform(0, 2))
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
##########################
|
|
|
|
|
|
# Execute
|
|
|
|
|
|
####################
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:05:10 -05:00
|
|
|
|
def _build_request(
|
|
|
|
|
|
app,
|
|
|
|
|
|
token: Optional[str] = None,
|
|
|
|
|
|
) -> Request:
|
2026-03-31 23:36:01 -05:00
|
|
|
|
"""Build a minimal ASGI Request for chat_completion.
|
|
|
|
|
|
|
|
|
|
|
|
Mirrors the mock-request pattern used in main.py lifespan
|
|
|
|
|
|
(model pre-fetch, tool server init) for consistency.
|
2026-06-29 00:05:10 -05:00
|
|
|
|
|
|
|
|
|
|
When token is provided, attach it as
|
|
|
|
|
|
request.state.token so session-auth tool servers and terminals can
|
|
|
|
|
|
authenticate headless scheduled runs as the automation owner.
|
2026-03-31 23:36:01 -05:00
|
|
|
|
"""
|
|
|
|
|
|
scope = {
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'type': 'http',
|
|
|
|
|
|
'asgi': {'version': '3.0', 'spec_version': '2.0'},
|
|
|
|
|
|
'method': 'POST',
|
|
|
|
|
|
'path': '/api/v1/automations/internal',
|
|
|
|
|
|
'query_string': b'',
|
|
|
|
|
|
'headers': Headers({}).raw,
|
|
|
|
|
|
'client': ('127.0.0.1', 0),
|
|
|
|
|
|
'server': ('127.0.0.1', 80),
|
|
|
|
|
|
'scheme': 'http',
|
|
|
|
|
|
'app': app,
|
2026-03-31 23:36:01 -05:00
|
|
|
|
}
|
|
|
|
|
|
request = Request(scope)
|
|
|
|
|
|
# Ensure request.state is initialized with required attributes
|
2026-06-29 00:05:10 -05:00
|
|
|
|
request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=token) if token else None
|
2026-03-31 23:36:01 -05:00
|
|
|
|
request.state.enable_api_keys = False
|
|
|
|
|
|
return request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_model_tool_ids(app, model_id: str) -> list[str]:
|
|
|
|
|
|
"""Read model-attached tool_ids from model config.
|
|
|
|
|
|
|
|
|
|
|
|
The frontend does this in Chat.svelte (model.info.meta.toolIds).
|
|
|
|
|
|
The backend never auto-resolves them, so we must do it explicitly.
|
|
|
|
|
|
"""
|
2026-04-01 04:36:02 -05:00
|
|
|
|
models = getattr(app.state, 'MODELS', {})
|
2026-03-31 23:36:01 -05:00
|
|
|
|
model = models.get(model_id, {})
|
2026-04-01 04:36:02 -05:00
|
|
|
|
tool_ids = model.get('info', {}).get('meta', {}).get('toolIds', [])
|
2026-03-31 23:36:01 -05:00
|
|
|
|
return list(tool_ids) if tool_ids else []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 02:52:35 +02:00
|
|
|
|
async def _resolve_model_features(app, model_id: str) -> dict:
|
2026-03-31 23:36:01 -05:00
|
|
|
|
"""Read model default features from model config.
|
|
|
|
|
|
|
|
|
|
|
|
The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds
|
|
|
|
|
|
+ model.info.meta.capabilities). Enables features like web_search,
|
|
|
|
|
|
code_interpreter, image_generation when the model has them as defaults
|
|
|
|
|
|
AND the capability is enabled AND the admin has enabled the feature.
|
|
|
|
|
|
"""
|
2026-04-01 04:36:02 -05:00
|
|
|
|
models = getattr(app.state, 'MODELS', {})
|
2026-03-31 23:36:01 -05:00
|
|
|
|
model = models.get(model_id, {})
|
2026-04-01 04:36:02 -05:00
|
|
|
|
meta = model.get('info', {}).get('meta', {})
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
2026-04-01 04:36:02 -05:00
|
|
|
|
default_feature_ids = meta.get('defaultFeatureIds', [])
|
2026-03-31 23:36:01 -05:00
|
|
|
|
if not default_feature_ids:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
2026-07-01 02:49:28 -05:00
|
|
|
|
capabilities = meta.get('capabilities') or {}
|
2026-03-31 23:36:01 -05:00
|
|
|
|
features = {}
|
|
|
|
|
|
|
2026-04-01 00:17:04 -05:00
|
|
|
|
# code_interpreter is excluded: it requires the frontend event emitter
|
|
|
|
|
|
# and does not work in headless backend execution.
|
2026-03-31 23:36:01 -05:00
|
|
|
|
feature_checks = {
|
2026-06-29 04:43:40 -05:00
|
|
|
|
'web_search': await Config.get('web.search.enable'),
|
2026-06-17 02:52:35 +02:00
|
|
|
|
'image_generation': await Config.get('image_generation.enable'),
|
2026-03-31 23:36:01 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for feature_id in default_feature_ids:
|
|
|
|
|
|
if feature_id in feature_checks:
|
|
|
|
|
|
# Feature must be: in defaultFeatureIds + capability enabled + admin enabled
|
|
|
|
|
|
if capabilities.get(feature_id) and feature_checks[feature_id]:
|
|
|
|
|
|
features[feature_id] = True
|
|
|
|
|
|
|
|
|
|
|
|
return features
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_model_filter_ids(app, model_id: str) -> list[str]:
|
|
|
|
|
|
"""Read model default filter_ids from model config."""
|
2026-04-01 04:36:02 -05:00
|
|
|
|
models = getattr(app.state, 'MODELS', {})
|
2026-03-31 23:36:01 -05:00
|
|
|
|
model = models.get(model_id, {})
|
2026-04-01 04:36:02 -05:00
|
|
|
|
filter_ids = model.get('info', {}).get('meta', {}).get('defaultFilterIds', [])
|
2026-03-31 23:36:01 -05:00
|
|
|
|
return list(filter_ids) if filter_ids else []
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 16:47:23 -05:00
|
|
|
|
def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]:
|
|
|
|
|
|
"""Read model default terminal_id from model config.
|
|
|
|
|
|
|
|
|
|
|
|
The frontend does this in Chat.svelte (model.info.meta.terminalId).
|
|
|
|
|
|
"""
|
|
|
|
|
|
models = getattr(app.state, 'MODELS', {})
|
|
|
|
|
|
model = models.get(model_id, {})
|
|
|
|
|
|
return model.get('info', {}).get('meta', {}).get('terminalId') or None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-01 04:36:02 -05:00
|
|
|
|
async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None:
|
2026-04-01 00:35:11 -05:00
|
|
|
|
"""Set the working directory on a terminal server via the proxy.
|
|
|
|
|
|
|
|
|
|
|
|
Routes through the open-webui terminal proxy endpoint so that
|
|
|
|
|
|
auth headers, orchestrator policy routing, and X-User-Id are
|
|
|
|
|
|
handled correctly — same path the frontend uses.
|
|
|
|
|
|
"""
|
|
|
|
|
|
import aiohttp
|
2026-04-20 08:34:15 +09:00
|
|
|
|
from open_webui.env import AIOHTTP_CLIENT_SESSION_SSL
|
2026-04-01 00:35:11 -05:00
|
|
|
|
|
2026-04-01 04:36:02 -05:00
|
|
|
|
connections = getattr(getattr(app, 'state', None), 'config', None)
|
2026-04-01 00:35:11 -05:00
|
|
|
|
if connections is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
connections = getattr(connections, 'TERMINAL_SERVER_CONNECTIONS', None) or []
|
|
|
|
|
|
connection = next((c for c in connections if c.get('id') == server_id), None)
|
|
|
|
|
|
if connection is None:
|
|
|
|
|
|
log.warning(f'Terminal server {server_id} not found for CWD set')
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
base_url = (connection.get('url') or '').rstrip('/')
|
|
|
|
|
|
if not base_url:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Build target URL — route through orchestrator policy if configured
|
|
|
|
|
|
policy_id = connection.get('policy_id')
|
|
|
|
|
|
if connection.get('server_type') == 'orchestrator' and policy_id:
|
|
|
|
|
|
target_url = f'{base_url}/p/{policy_id}/files/cwd'
|
|
|
|
|
|
else:
|
|
|
|
|
|
target_url = f'{base_url}/files/cwd'
|
|
|
|
|
|
|
|
|
|
|
|
headers = {'Content-Type': 'application/json', 'X-User-Id': user.id}
|
|
|
|
|
|
if chat_id:
|
|
|
|
|
|
headers['X-Session-Id'] = chat_id
|
|
|
|
|
|
|
|
|
|
|
|
auth_type = connection.get('auth_type', 'bearer')
|
|
|
|
|
|
if auth_type == 'bearer':
|
|
|
|
|
|
headers['Authorization'] = f'Bearer {connection.get("key", "")}'
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-01 04:36:02 -05:00
|
|
|
|
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) as session:
|
2026-04-01 00:35:11 -05:00
|
|
|
|
async with session.post(
|
|
|
|
|
|
target_url,
|
|
|
|
|
|
json={'path': cwd},
|
|
|
|
|
|
headers=headers,
|
2026-04-20 08:34:15 +09:00
|
|
|
|
ssl=AIOHTTP_CLIENT_SESSION_SSL,
|
2026-04-01 00:35:11 -05:00
|
|
|
|
) as resp:
|
|
|
|
|
|
if resp.status != 200:
|
|
|
|
|
|
body = await resp.text()
|
2026-04-01 04:36:02 -05:00
|
|
|
|
log.warning(f'Failed to set terminal CWD to {cwd}: HTTP {resp.status} — {body[:200]}')
|
2026-04-01 00:35:11 -05:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log.warning(f'Failed to set terminal CWD: {e}')
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-31 23:36:01 -05:00
|
|
|
|
async def execute_automation(app, automation: AutomationModel) -> None:
|
|
|
|
|
|
"""Execute an automation through the full chat completion pipeline.
|
|
|
|
|
|
|
|
|
|
|
|
Creates a real chat, then calls chat_completion exactly like the frontend:
|
|
|
|
|
|
session_id + chat_id + message_id → async task → pipeline handles everything
|
|
|
|
|
|
(filters, model params, knowledge/RAG, tools, DB saves, webhooks).
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-04-12 14:22:11 -05:00
|
|
|
|
user = await Users.get_user_by_id(automation.user_id)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
if not user:
|
2026-04-12 14:22:11 -05:00
|
|
|
|
await _record_run(automation.id, 'error', error='User not found')
|
2026-06-25 03:31:45 +01:00
|
|
|
|
await publish_event(
|
|
|
|
|
|
app,
|
|
|
|
|
|
EVENTS.AUTOMATION_RUN_FAILED,
|
|
|
|
|
|
subject_id=automation.id,
|
|
|
|
|
|
data={'name': automation.name, 'error': 'User not found'},
|
|
|
|
|
|
)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-06-16 22:45:27 +02:00
|
|
|
|
# Re-gate the rehydrated owner: a demoted/deactivated or de-permissioned owner must not run.
|
|
|
|
|
|
from open_webui.utils.access_control import has_permission
|
|
|
|
|
|
|
|
|
|
|
|
if user.role not in ('user', 'admin') or (
|
|
|
|
|
|
user.role != 'admin'
|
2026-06-17 02:52:35 +02:00
|
|
|
|
and not await has_permission(user.id, 'features.automations', await Config.get('user.permissions'))
|
2026-06-16 22:45:27 +02:00
|
|
|
|
):
|
2026-06-25 03:31:45 +01:00
|
|
|
|
error = 'Owner no longer permitted to run automations'
|
|
|
|
|
|
await _record_run(automation.id, 'error', error=error)
|
|
|
|
|
|
await publish_event(
|
|
|
|
|
|
app,
|
|
|
|
|
|
EVENTS.AUTOMATION_RUN_FAILED,
|
|
|
|
|
|
actor=user,
|
|
|
|
|
|
subject_id=automation.id,
|
|
|
|
|
|
data={'name': automation.name, 'error': error},
|
|
|
|
|
|
)
|
2026-06-16 22:45:27 +02:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-05-09 04:17:58 +09:00
|
|
|
|
prompt = await prompt_template(automation.data['prompt'], user)
|
2026-04-01 04:36:02 -05:00
|
|
|
|
model_id = automation.data['model_id']
|
|
|
|
|
|
terminal_config = automation.data.get('terminal')
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
# Generate proper UUIDs for messages (same as frontend)
|
|
|
|
|
|
user_msg_id = str(uuid4())
|
|
|
|
|
|
assistant_msg_id = str(uuid4())
|
|
|
|
|
|
|
2026-04-13 21:29:03 -05:00
|
|
|
|
chat_id = str(uuid4())
|
2026-04-12 14:22:11 -05:00
|
|
|
|
chat = await Chats.insert_new_chat(
|
2026-04-13 21:29:03 -05:00
|
|
|
|
chat_id,
|
2026-03-31 23:36:01 -05:00
|
|
|
|
automation.user_id,
|
|
|
|
|
|
ChatForm(
|
|
|
|
|
|
chat={
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'title': automation.name,
|
|
|
|
|
|
'models': [model_id],
|
|
|
|
|
|
'history': {
|
|
|
|
|
|
'currentId': assistant_msg_id,
|
|
|
|
|
|
'messages': {
|
2026-03-31 23:36:01 -05:00
|
|
|
|
user_msg_id: {
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'id': user_msg_id,
|
|
|
|
|
|
'parentId': None,
|
|
|
|
|
|
'role': 'user',
|
|
|
|
|
|
'content': prompt,
|
|
|
|
|
|
'childrenIds': [assistant_msg_id],
|
|
|
|
|
|
'timestamp': int(time.time()),
|
|
|
|
|
|
'models': [model_id],
|
2026-03-31 23:36:01 -05:00
|
|
|
|
},
|
|
|
|
|
|
assistant_msg_id: {
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'id': assistant_msg_id,
|
|
|
|
|
|
'parentId': user_msg_id,
|
|
|
|
|
|
'role': 'assistant',
|
|
|
|
|
|
'content': '',
|
|
|
|
|
|
'done': False,
|
|
|
|
|
|
'model': model_id,
|
|
|
|
|
|
'childrenIds': [],
|
|
|
|
|
|
'timestamp': int(time.time()),
|
2026-03-31 23:36:01 -05:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'messages': [
|
|
|
|
|
|
{'role': 'user', 'content': prompt},
|
2026-03-31 23:36:01 -05:00
|
|
|
|
],
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'meta': {'automation_id': automation.id},
|
2026-03-31 23:36:01 -05:00
|
|
|
|
}
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not chat:
|
2026-06-25 03:31:45 +01:00
|
|
|
|
error = 'Failed to create chat'
|
|
|
|
|
|
await _record_run(automation.id, 'error', error=error)
|
|
|
|
|
|
await publish_event(
|
|
|
|
|
|
app,
|
|
|
|
|
|
EVENTS.AUTOMATION_RUN_FAILED,
|
|
|
|
|
|
actor=user,
|
|
|
|
|
|
subject_id=automation.id,
|
|
|
|
|
|
data={'name': automation.name, 'error': error},
|
|
|
|
|
|
)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Notify frontend to refresh chat list
|
|
|
|
|
|
from open_webui.socket.main import sio
|
|
|
|
|
|
|
|
|
|
|
|
await sio.emit(
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'events',
|
2026-03-31 23:36:01 -05:00
|
|
|
|
{
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'chat_id': chat.id,
|
|
|
|
|
|
'message_id': user_msg_id,
|
|
|
|
|
|
'data': {'type': 'chat:list'},
|
2026-03-31 23:36:01 -05:00
|
|
|
|
},
|
2026-04-01 04:36:02 -05:00
|
|
|
|
room=f'user:{automation.user_id}',
|
2026-03-31 23:36:01 -05:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Resolve model defaults (frontend does this, backend doesn't)
|
|
|
|
|
|
tool_ids = _resolve_model_tool_ids(app, model_id)
|
2026-06-17 02:52:35 +02:00
|
|
|
|
features = await _resolve_model_features(app, model_id)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
filter_ids = _resolve_model_filter_ids(app, model_id)
|
|
|
|
|
|
|
2026-04-12 16:47:23 -05:00
|
|
|
|
# Resolve terminal from model config
|
|
|
|
|
|
terminal_id = _resolve_model_terminal_id(app, model_id)
|
2026-04-01 00:35:11 -05:00
|
|
|
|
|
2026-03-31 23:36:01 -05:00
|
|
|
|
# Build the same payload the frontend sends to /api/chat/completions
|
|
|
|
|
|
form_data = {
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'model': model_id,
|
|
|
|
|
|
'messages': [{'role': 'user', 'content': prompt}],
|
|
|
|
|
|
'stream': True,
|
|
|
|
|
|
'chat_id': chat.id,
|
|
|
|
|
|
'id': assistant_msg_id,
|
2026-04-13 21:29:03 -05:00
|
|
|
|
'parent_id': None, # Root message (chat already created above)
|
|
|
|
|
|
'user_message': {
|
|
|
|
|
|
'id': user_msg_id,
|
|
|
|
|
|
'parentId': None,
|
|
|
|
|
|
'role': 'user',
|
|
|
|
|
|
'content': prompt,
|
|
|
|
|
|
},
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'session_id': f'automation:{automation.id}',
|
|
|
|
|
|
'background_tasks': {},
|
2026-03-31 23:36:01 -05:00
|
|
|
|
}
|
|
|
|
|
|
if tool_ids:
|
2026-04-01 04:36:02 -05:00
|
|
|
|
form_data['tool_ids'] = tool_ids
|
2026-03-31 23:36:01 -05:00
|
|
|
|
if features:
|
2026-04-01 04:36:02 -05:00
|
|
|
|
form_data['features'] = features
|
2026-03-31 23:36:01 -05:00
|
|
|
|
if filter_ids:
|
2026-04-01 04:36:02 -05:00
|
|
|
|
form_data['filter_ids'] = filter_ids
|
2026-04-01 00:35:11 -05:00
|
|
|
|
if terminal_id:
|
2026-04-01 04:36:02 -05:00
|
|
|
|
form_data['terminal_id'] = terminal_id
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
2026-03-31 23:39:54 -05:00
|
|
|
|
# Call the full chat completion pipeline (same as POST /api/chat/completions).
|
|
|
|
|
|
# The handler reference is stored on app.state to avoid circular imports.
|
2026-06-29 00:05:10 -05:00
|
|
|
|
try:
|
|
|
|
|
|
expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h')))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
expires_delta = None
|
|
|
|
|
|
token = create_token(
|
|
|
|
|
|
data={'id': user.id, 'typ': 'automation'},
|
|
|
|
|
|
expires_delta=expires_delta or timedelta(hours=1),
|
|
|
|
|
|
)
|
|
|
|
|
|
request = _build_request(app, token=token)
|
2026-03-31 23:39:54 -05:00
|
|
|
|
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
# Notify user
|
|
|
|
|
|
from open_webui.socket.main import sio
|
|
|
|
|
|
|
|
|
|
|
|
await sio.emit(
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'automation:result',
|
2026-03-31 23:36:01 -05:00
|
|
|
|
{
|
2026-04-01 04:36:02 -05:00
|
|
|
|
'automation_id': automation.id,
|
|
|
|
|
|
'name': automation.name,
|
|
|
|
|
|
'chat_id': chat.id,
|
|
|
|
|
|
'status': 'success',
|
2026-03-31 23:36:01 -05:00
|
|
|
|
},
|
2026-04-01 04:36:02 -05:00
|
|
|
|
room=f'user:{automation.user_id}',
|
2026-03-31 23:36:01 -05:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
|
await _record_run(automation.id, 'success', chat_id=chat.id)
|
2026-06-25 03:31:45 +01:00
|
|
|
|
await publish_event(
|
|
|
|
|
|
app,
|
|
|
|
|
|
EVENTS.AUTOMATION_RUN_COMPLETED,
|
|
|
|
|
|
actor=user,
|
|
|
|
|
|
subject_id=automation.id,
|
|
|
|
|
|
data={'name': automation.name, 'chat_id': chat.id},
|
|
|
|
|
|
)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2026-04-01 04:36:02 -05:00
|
|
|
|
log.exception(f'Automation {automation.id} failed')
|
2026-06-25 03:31:45 +01:00
|
|
|
|
error = str(e)[:4000]
|
|
|
|
|
|
await _record_run(automation.id, 'error', error=error)
|
|
|
|
|
|
await publish_event(
|
|
|
|
|
|
app,
|
|
|
|
|
|
EVENTS.AUTOMATION_RUN_FAILED,
|
|
|
|
|
|
subject_id=automation.id,
|
|
|
|
|
|
data={'name': automation.name, 'error': error},
|
|
|
|
|
|
)
|
2026-03-31 23:36:01 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
####################
|
|
|
|
|
|
# Internals
|
|
|
|
|
|
####################
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-19 23:38:58 +09:00
|
|
|
|
async def _check_calendar_alerts(app) -> None:
|
|
|
|
|
|
"""Check for upcoming calendar events and send alert notifications.
|
|
|
|
|
|
|
|
|
|
|
|
De-duplication is DB-backed via meta.alerted_at — survives restarts
|
|
|
|
|
|
and works across multiple instances.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from open_webui.models.calendar import CalendarEvents, CalendarEventUpdateForm
|
|
|
|
|
|
from open_webui.socket.main import sio
|
|
|
|
|
|
|
|
|
|
|
|
now_ns = int(time.time_ns())
|
|
|
|
|
|
default_lookahead_ns = CALENDAR_ALERT_LOOKAHEAD_MINUTES * 60 * 1_000_000_000
|
2026-06-01 14:09:54 -07:00
|
|
|
|
# Grace window covers one poll cycle + jitter so "At time of event"
|
|
|
|
|
|
# alerts (alert_minutes=0) are not missed.
|
|
|
|
|
|
grace_ns = (SCHEDULER_POLL_INTERVAL + 5) * 1_000_000_000
|
2026-04-19 23:38:58 +09:00
|
|
|
|
|
|
|
|
|
|
async with get_async_db() as db:
|
2026-06-01 14:10:40 -07:00
|
|
|
|
upcoming = await CalendarEvents.get_upcoming_events(now_ns, default_lookahead_ns, grace_ns=grace_ns, db=db)
|
2026-04-19 23:38:58 +09:00
|
|
|
|
|
|
|
|
|
|
if not upcoming:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
for event, user_tz in upcoming:
|
|
|
|
|
|
# Skip if already alerted for this start time
|
|
|
|
|
|
if event.meta and event.meta.get('alerted_at'):
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Compute minutes until event starts
|
|
|
|
|
|
minutes_until = max(0, int((event.start_at - now_ns) / (60 * 1_000_000_000)))
|
|
|
|
|
|
|
|
|
|
|
|
alert_data = {
|
|
|
|
|
|
'event_id': event.id,
|
|
|
|
|
|
'title': event.title,
|
|
|
|
|
|
'description': event.description or '',
|
|
|
|
|
|
'start_at': event.start_at,
|
|
|
|
|
|
'minutes_until': minutes_until,
|
|
|
|
|
|
'calendar_id': event.calendar_id,
|
|
|
|
|
|
'location': event.location or '',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await sio.emit(
|
|
|
|
|
|
'events',
|
|
|
|
|
|
{
|
|
|
|
|
|
'data': {
|
|
|
|
|
|
'type': 'calendar:alert',
|
|
|
|
|
|
'data': alert_data,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
room=f'user:{event.user_id}',
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Mark as alerted in DB so it survives restarts / multi-instance
|
|
|
|
|
|
try:
|
|
|
|
|
|
await CalendarEvents.update_event_by_id(
|
|
|
|
|
|
event.id,
|
|
|
|
|
|
CalendarEventUpdateForm(meta={'alerted_at': now_ns}),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
log.debug(f'Failed to mark event {event.id} as alerted', exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
# Send webhook notification if user has one configured
|
|
|
|
|
|
try:
|
|
|
|
|
|
webui_name = getattr(app.state, 'WEBUI_NAME', 'Open WebUI')
|
2026-06-17 02:52:35 +02:00
|
|
|
|
enable_user_webhooks = await Config.get('ui.enable_user_webhooks')
|
2026-04-19 23:38:58 +09:00
|
|
|
|
|
|
|
|
|
|
if enable_user_webhooks:
|
|
|
|
|
|
user = await Users.get_user_by_id(event.user_id)
|
|
|
|
|
|
if user and user.settings:
|
|
|
|
|
|
webhook_url = (
|
|
|
|
|
|
user.settings.get('ui', {}).get('notifications', {}).get('webhook_url', None)
|
|
|
|
|
|
if isinstance(user.settings, dict)
|
|
|
|
|
|
else getattr(getattr(user.settings, 'ui', None), 'get', lambda *a: None)(
|
|
|
|
|
|
'notifications', {}
|
|
|
|
|
|
).get('webhook_url', None)
|
|
|
|
|
|
if hasattr(user.settings, 'ui')
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
|
|
|
|
|
if webhook_url:
|
|
|
|
|
|
from open_webui.utils.webhook import post_webhook
|
|
|
|
|
|
|
|
|
|
|
|
time_str = f'in {minutes_until} min' if minutes_until > 0 else 'now'
|
|
|
|
|
|
await post_webhook(
|
|
|
|
|
|
webui_name,
|
|
|
|
|
|
webhook_url,
|
|
|
|
|
|
f'{event.title} — starting {time_str}',
|
|
|
|
|
|
{
|
|
|
|
|
|
'action': 'calendar_alert',
|
|
|
|
|
|
'title': event.title,
|
|
|
|
|
|
'minutes_until': minutes_until,
|
|
|
|
|
|
'event_id': event.id,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
log.debug(f'Failed to send webhook for calendar alert {event.id}', exc_info=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 14:22:11 -05:00
|
|
|
|
async def _record_run(
|
2026-03-31 23:36:01 -05:00
|
|
|
|
automation_id: str,
|
|
|
|
|
|
status: str,
|
|
|
|
|
|
chat_id: str = None,
|
|
|
|
|
|
error: str = None,
|
|
|
|
|
|
):
|
|
|
|
|
|
"""Insert a run record into automation_run."""
|
2026-04-12 22:10:43 -05:00
|
|
|
|
async with get_async_db() as db:
|
2026-04-12 14:22:11 -05:00
|
|
|
|
await AutomationRuns.insert(automation_id, status, chat_id=chat_id, error=error, db=db)
|