open-swe/agent/dashboard/schedules.py
Johannes du Plessis 6895ddcedc
feat: add scheduled web agents (#1422)
* feat: add scheduled web agents

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>

* fix: secure scheduled agent repositories

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>

* feat: rebuild scheduled agents as Automations tab

Scrap the inline ScheduledAgentsPanel and replace it with a dedicated
Automations tab: sidebar nav entry, list view with stat cards + empty
state, and a full editor (name, Active toggle, repo, scheduled trigger
picker, agent instructions + model).

* fix: clear collapsed-sidebar button on mobile in Automations

* fix: allow clearing automation repo on update

---------

Co-authored-by: open-swe[bot] <215916821+open-swe[bot]@users.noreply.github.com>
2026-06-05 02:20:24 +00:00

467 lines
17 KiB
Python

"""Dashboard-managed recurring agent schedules."""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException
from pydantic import BaseModel, Field, field_validator
from ..utils.thread_ops import langgraph_client
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .profiles import get_profile, get_valid_access_token
from .repo_access import repo_config_for_user, require_repo_access_for_user
from .thread_api import _agent_version_metadata, _now_ms, _resolve_run_email
logger = logging.getLogger(__name__)
SCHEDULES_NAMESPACE: list[str] = ["agent_schedules"]
_AGENT_ASSISTANT_ID = "agent"
_SCHEDULER_ASSISTANT_ID = "scheduler"
_CRON_FIELD_RANGES = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
class ScheduleCreateBody(BaseModel):
prompt: str = Field(min_length=1, max_length=20_000)
schedule: str = Field(min_length=1, max_length=120)
name: str | None = Field(default=None, max_length=120)
repo: str | None = None
model_id: str | None = None
effort: str | None = None
@field_validator("schedule")
@classmethod
def _valid_schedule(cls, value: str) -> str:
return normalize_cron_schedule(value)
class ScheduleUpdateBody(BaseModel):
prompt: str | None = Field(default=None, min_length=1, max_length=20_000)
schedule: str | None = Field(default=None, min_length=1, max_length=120)
name: str | None = Field(default=None, max_length=120)
repo: str | None = None
model_id: str | None = None
effort: str | None = None
enabled: bool | None = None
@field_validator("schedule")
@classmethod
def _valid_schedule(cls, value: str | None) -> str | None:
return normalize_cron_schedule(value) if value is not None else None
def _client():
return langgraph_client()
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def _normalize_model_choice(
model_id: str | None, effort: str | None
) -> tuple[str | None, str | None]:
if not isinstance(model_id, str) or model_id not in SUPPORTED_MODEL_IDS:
return None, None
if not isinstance(effort, str) or not model_supports_effort(model_id, effort):
return None, None
return model_id, effort
def _validate_cron_value(value: str, low: int, high: int) -> None:
try:
n = int(value)
except ValueError as exc:
raise ValueError("cron fields must use numbers, *, ranges, steps, or lists") from exc
if n < low or n > high:
raise ValueError(f"cron value {n} outside allowed range {low}-{high}")
def _validate_cron_field(field: str, low: int, high: int) -> None:
for segment in field.split(","):
if not segment:
raise ValueError("cron fields cannot contain empty list segments")
base, sep, step = segment.partition("/")
if sep:
_validate_cron_value(step, 1, high)
if base == "*":
continue
start, dash, end = base.partition("-")
if dash:
_validate_cron_value(start, low, high)
_validate_cron_value(end, low, high)
if int(start) > int(end):
raise ValueError("cron ranges must be ascending")
else:
_validate_cron_value(base, low, high)
def normalize_cron_schedule(raw: str) -> str:
value = " ".join(raw.strip().split())
parts = value.split(" ")
if len(parts) != 5:
raise ValueError("schedule must be a five-field cron expression")
for part, (low, high) in zip(parts, _CRON_FIELD_RANGES, strict=True):
_validate_cron_field(part, low, high)
return value
def _derive_name(prompt: str) -> str:
return prompt.strip().splitlines()[0][:80] or "Scheduled agent"
def _repo_full_name(repo: dict[str, str] | None) -> str | None:
if not repo:
return None
owner = repo.get("owner")
name = repo.get("name")
return f"{owner}/{name}" if owner and name else None
def _schedule_summary(record: dict[str, Any]) -> dict[str, Any]:
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
return {
"id": record.get("id"),
"name": record.get("name"),
"prompt": record.get("prompt"),
"schedule": record.get("schedule"),
"repo": _repo_full_name(repo),
"model": record.get("model"),
"effort": record.get("effort"),
"enabled": bool(record.get("enabled")),
"cronId": record.get("cron_id"),
"lastThreadId": record.get("last_thread_id"),
"lastRunId": record.get("last_run_id"),
"lastTriggeredAt": record.get("last_triggered_at"),
"lastError": record.get("last_error"),
"lastErrorAt": record.get("last_error_at"),
"createdAt": record.get("created_at"),
"updatedAt": record.get("updated_at"),
}
async def _get_value(schedule_id: str) -> dict[str, Any] | None:
item = await _client().store.get_item(SCHEDULES_NAMESPACE, schedule_id)
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
async def _put_value(record: dict[str, Any]) -> dict[str, Any]:
record = {**record, "updated_at": _now_iso()}
await _client().store.put_item(SCHEDULES_NAMESPACE, record["id"], record)
return record
async def get_agent_schedule(schedule_id: str) -> dict[str, Any] | None:
return await _get_value(schedule_id)
def _user_owns_schedule(record: dict[str, Any], login: str, email: str | None = None) -> bool:
if record.get("created_by") == login:
return True
record_email = record.get("user_email")
return bool(email and isinstance(record_email, str) and record_email == email.strip().lower())
def _assert_schedule_owner(
record: dict[str, Any] | None, login: str, email: str | None = None
) -> None:
if not record or not _user_owns_schedule(record, login, email):
raise HTTPException(404, "schedule not found")
async def _search_schedule_values(filter: dict[str, Any]) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
limit = 100
offset = 0
while True:
result = await _client().store.search_items(
SCHEDULES_NAMESPACE,
filter=filter,
limit=limit,
offset=offset,
)
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
if not items:
break
for item in items:
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
if isinstance(value, dict):
records.append(value)
if len(items) < limit:
break
offset += len(items)
return records
async def list_agent_schedules(login: str, *, email: str | None = None) -> list[dict[str, Any]]:
searches: list[dict[str, Any]] = [{"created_by": login}]
if email and email.strip():
searches.append({"user_email": email.strip().lower()})
seen: dict[str, dict[str, Any]] = {}
for filter in searches:
for record in await _search_schedule_values(filter):
schedule_id = record.get("id")
if isinstance(schedule_id, str) and _user_owns_schedule(record, login, email):
seen[schedule_id] = record
records = list(seen.values())
records.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
return [_schedule_summary(record) for record in records]
async def _ensure_dashboard_github_token(login: str) -> None:
token = await get_valid_access_token(login)
if not token:
raise HTTPException(401, "github token unavailable, re-login required")
def _build_cron_config(record: dict[str, Any]) -> dict[str, Any]:
return {
"configurable": {
"schedule_id": record["id"],
},
"metadata": _agent_version_metadata(),
}
async def _create_cron(record: dict[str, Any]) -> str:
cron = await _client().crons.create(
_SCHEDULER_ASSISTANT_ID,
schedule=record["schedule"],
input={"schedule_id": record["id"]},
config=_build_cron_config(record),
metadata={
"kind": "agent_schedule",
"schedule_id": record["id"],
"github_login": record.get("created_by"),
},
)
cron_id = cron.get("cron_id") if isinstance(cron, dict) else getattr(cron, "cron_id", None)
if not isinstance(cron_id, str) or not cron_id:
raise RuntimeError("cron creation did not return a cron_id")
return cron_id
async def _delete_cron(cron_id: str | None) -> None:
if not cron_id:
return
try:
await _client().crons.delete(cron_id)
except Exception:
logger.debug("Could not delete schedule cron %s", cron_id, exc_info=True)
async def create_agent_schedule(
login: str, body: ScheduleCreateBody, *, email: str | None = None
) -> dict[str, Any]:
await _ensure_dashboard_github_token(login)
profile = await get_profile(login) or {}
chosen_model, chosen_effort = _normalize_model_choice(body.model_id, body.effort)
repo = await repo_config_for_user(login, body.repo)
schedule_id = str(uuid.uuid4())
now = _now_iso()
record: dict[str, Any] = {
"id": schedule_id,
"name": (body.name or _derive_name(body.prompt)).strip(),
"prompt": body.prompt.strip(),
"schedule": body.schedule,
"repo": repo,
"model": chosen_model or profile.get("default_model") or "Default",
"effort": chosen_effort or profile.get("reasoning_effort"),
"base_branch": profile.get("base_branch") or "main",
"branch_prefix": profile.get("branch_prefix"),
"enabled": True,
"cron_id": None,
"last_thread_id": None,
"last_run_id": None,
"last_triggered_at": None,
"last_error": None,
"last_error_at": None,
"created_by": login,
"user_email": (await _resolve_run_email(login, profile) or email or "").strip().lower(),
"created_at": now,
"updated_at": now,
}
await _put_value(record)
try:
cron_id = await _create_cron(record)
except Exception as exc:
await _client().store.delete_item(SCHEDULES_NAMESPACE, schedule_id)
logger.exception("Failed to create schedule cron for %s", schedule_id)
raise HTTPException(502, "failed to create schedule cron") from exc
record = await _put_value({**record, "cron_id": cron_id})
return _schedule_summary(record)
async def update_agent_schedule(
schedule_id: str, login: str, body: ScheduleUpdateBody, *, email: str | None = None
) -> dict[str, Any]:
existing = await get_agent_schedule(schedule_id)
_assert_schedule_owner(existing, login, email)
assert existing is not None
patch: dict[str, Any] = {}
if body.prompt is not None:
patch["prompt"] = body.prompt.strip()
if body.schedule is not None:
patch["schedule"] = body.schedule
if body.name is not None:
patch["name"] = body.name.strip() or _derive_name(patch.get("prompt", existing["prompt"]))
if body.repo is not None:
patch["repo"] = await repo_config_for_user(login, body.repo)
if body.model_id is not None or body.effort is not None:
model, effort = _normalize_model_choice(body.model_id, body.effort)
if model and effort:
patch["model"] = model
patch["effort"] = effort
if body.enabled is not None:
patch["enabled"] = body.enabled
updated = {**existing, **patch}
schedule_changed = updated.get("schedule") != existing.get("schedule")
enabled_changed = updated.get("enabled") != existing.get("enabled")
needs_new_cron = bool(updated.get("enabled")) and (schedule_changed or enabled_changed)
if needs_new_cron:
try:
new_cron_id = await _create_cron(updated)
except Exception as exc:
logger.exception("Failed to recreate schedule cron for %s", schedule_id)
raise HTTPException(502, "failed to create schedule cron") from exc
await _delete_cron(existing.get("cron_id"))
updated["cron_id"] = new_cron_id
elif updated.get("enabled") is False and existing.get("cron_id"):
await _delete_cron(existing.get("cron_id"))
updated["cron_id"] = None
updated = await _put_value(updated)
return _schedule_summary(updated)
async def delete_agent_schedule(schedule_id: str, login: str, *, email: str | None = None) -> None:
existing = await get_agent_schedule(schedule_id)
_assert_schedule_owner(existing, login, email)
assert existing is not None
await _delete_cron(existing.get("cron_id"))
await _client().store.delete_item(SCHEDULES_NAMESPACE, schedule_id)
def _agent_run_metadata(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
now_ms = _now_ms()
metadata: dict[str, Any] = {
"source": "schedule",
"schedule_id": record["id"],
"schedule_name": record.get("name"),
"github_login": record.get("created_by"),
"triggering_user_email": record.get("user_email"),
"title": f"Scheduled: {record.get('name') or 'Agent'}",
"base_branch": record.get("base_branch") or "main",
"branch_prefix": record.get("branch_prefix"),
"model": record.get("model") or "Default",
"effort": record.get("effort"),
"created_at_ms": now_ms,
"updated_at_ms": now_ms,
}
if repo and repo.get("owner") and repo.get("name"):
metadata["repo_owner"] = repo["owner"]
metadata["repo_name"] = repo["name"]
return metadata
def _agent_run_config(record: dict[str, Any], thread_id: str) -> dict[str, Any]:
configurable: dict[str, Any] = {
"thread_id": thread_id,
"source": "schedule",
"github_login": record.get("created_by"),
"user_email": record.get("user_email"),
"schedule_id": record["id"],
}
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
if repo and repo.get("owner") and repo.get("name"):
configurable["repo"] = repo
model, effort = _normalize_model_choice(record.get("model"), record.get("effort"))
if model and effort:
configurable["agent_model_id"] = model
configurable["agent_effort"] = effort
return {"configurable": configurable, "metadata": _agent_version_metadata()}
async def launch_scheduled_agent_run(schedule_id: str) -> dict[str, Any]:
record = await get_agent_schedule(schedule_id)
if not record:
return {"status": "missing", "schedule_id": schedule_id}
if not record.get("enabled"):
return {"status": "disabled", "schedule_id": schedule_id}
repo = record.get("repo") if isinstance(record.get("repo"), dict) else None
full_name = _repo_full_name(repo)
login = record.get("created_by")
if full_name:
if not (isinstance(login, str) and login):
await _put_value(
{
**record,
"last_error": "schedule owner unavailable",
"last_error_at": _now_iso(),
}
)
return {
"status": "unauthorized",
"schedule_id": schedule_id,
"error": "schedule owner unavailable",
}
try:
await require_repo_access_for_user(login, full_name)
except HTTPException as exc:
await _put_value(
{
**record,
"last_error": str(exc.detail),
"last_error_at": _now_iso(),
}
)
return {"status": "unauthorized", "schedule_id": schedule_id, "error": exc.detail}
thread_id = str(uuid.uuid4())
metadata = _agent_run_metadata(record, thread_id)
client = _client()
await client.threads.create(thread_id=thread_id, metadata=metadata, if_exists="do_nothing")
await client.threads.update(thread_id=thread_id, metadata=metadata)
run = await client.runs.create(
thread_id,
_AGENT_ASSISTANT_ID,
input={"messages": [{"role": "user", "content": record["prompt"]}]},
config=_agent_run_config(record, thread_id),
if_not_exists="create",
stream_mode=["values", "updates", "messages-tuple"],
stream_resumable=True,
)
run_id = run.get("run_id") if isinstance(run, dict) else getattr(run, "run_id", None)
now_ms = _now_ms()
await client.threads.update(
thread_id=thread_id,
metadata={"latest_run_id": run_id, "latest_run_status": "pending", "updated_at_ms": now_ms},
)
await _put_value(
{
**record,
"last_thread_id": thread_id,
"last_run_id": run_id,
"last_triggered_at": _now_iso(),
"last_error": None,
"last_error_at": None,
}
)
return {
"status": "started",
"schedule_id": schedule_id,
"thread_id": thread_id,
"run_id": run_id,
}