From 6895ddcedc65f2bcb64cf21516f21dd742789477 Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Thu, 4 Jun 2026 19:20:24 -0700 Subject: [PATCH] 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> --- agent/dashboard/repo_access.py | 65 +++ agent/dashboard/routes.py | 103 ++-- agent/dashboard/schedules.py | 467 ++++++++++++++++++ agent/dashboard/thread_api.py | 2 +- agent/scheduler.py | 35 ++ agent/utils/auth.py | 6 +- langgraph.json | 3 +- tests/test_agent_schedules.py | 376 ++++++++++++++ ui/src/components/agents/AgentRunCard.tsx | 57 ++- ui/src/components/agents/AgentsSidebar.tsx | 128 ++--- ui/src/components/agents/AutomationEditor.tsx | 351 +++++++++++++ ui/src/components/agents/AutomationsList.tsx | 204 ++++++++ .../agents/ScheduleTriggerPicker.tsx | 78 +++ ui/src/lib/agents/api.ts | 152 ++++-- ui/src/lib/agents/cron.ts | 73 +++ ui/src/lib/agents/queries.ts | 142 ++++-- ui/src/lib/agents/types.ts | 209 ++++---- ui/src/routeTree.gen.ts | 78 ++- .../routes/agents/automations/$scheduleId.tsx | 46 ++ ui/src/routes/agents/automations/index.tsx | 20 + ui/src/routes/agents/automations/new.tsx | 20 + 21 files changed, 2288 insertions(+), 327 deletions(-) create mode 100644 agent/dashboard/repo_access.py create mode 100644 agent/dashboard/schedules.py create mode 100644 agent/scheduler.py create mode 100644 tests/test_agent_schedules.py create mode 100644 ui/src/components/agents/AutomationEditor.tsx create mode 100644 ui/src/components/agents/AutomationsList.tsx create mode 100644 ui/src/components/agents/ScheduleTriggerPicker.tsx create mode 100644 ui/src/lib/agents/cron.ts create mode 100644 ui/src/routes/agents/automations/$scheduleId.tsx create mode 100644 ui/src/routes/agents/automations/index.tsx create mode 100644 ui/src/routes/agents/automations/new.tsx diff --git a/agent/dashboard/repo_access.py b/agent/dashboard/repo_access.py new file mode 100644 index 00000000..ba51bbad --- /dev/null +++ b/agent/dashboard/repo_access.py @@ -0,0 +1,65 @@ +"""GitHub repository access checks for dashboard actions.""" + +from __future__ import annotations + +import httpx +from fastapi import HTTPException + +from .profiles import get_valid_access_token +from .review_styles import normalize_repo_full_name + + +def _raise_for_github_repo_status(status_code: int) -> None: + if status_code == 401: + raise HTTPException(401, "github token expired, re-login required") + if status_code == 404: + raise HTTPException(404, "repository not found") + if status_code == 403: + raise HTTPException(403, "no access to this private repository") + if status_code != 200: + raise HTTPException(502, f"github API error ({status_code})") + + +async def assert_repo_access(full_name: str, token: str) -> str: + full_name = normalize_repo_full_name(full_name) + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + owner, name = full_name.split("/", 1) + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://api.github.com/repos/{owner}/{name}", + headers=headers, + ) + _raise_for_github_repo_status(response.status_code) + return full_name + + +async def require_repo_access_for_user(login: str, full_name: str) -> str: + token = await get_valid_access_token(login) + if not token: + raise HTTPException(401, "github token unavailable, re-login required") + try: + await assert_repo_access(full_name, token) + except HTTPException as exc: + if exc.status_code != 401: + raise + token = await get_valid_access_token(login, force_refresh=True) + if not token: + raise HTTPException(401, "github token expired, re-login required") from exc + await assert_repo_access(full_name, token) + return token + + +async def repo_config_for_user(login: str, full_name: str | None) -> dict[str, str] | None: + if not isinstance(full_name, str) or not full_name.strip(): + return None + try: + normalized = normalize_repo_full_name(full_name) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + await require_repo_access_for_user(login, normalized) + owner, name = normalized.split("/", 1) + return {"owner": owner, "name": name} diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index e756d5eb..c15f23ab 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -43,6 +43,7 @@ from .profiles import ( upsert_access_token_from_github_response, upsert_profile, ) +from .repo_access import require_repo_access_for_user from .review_style_jobs import ( cancel_review_style_analysis, start_bootstrap_analysis, @@ -58,6 +59,14 @@ from .review_styles import ( normalize_repo_full_name, set_custom_prompt, ) +from .schedules import ( + ScheduleCreateBody, + ScheduleUpdateBody, + create_agent_schedule, + delete_agent_schedule, + list_agent_schedules, + update_agent_schedule, +) from .slack_oauth import ( SLACK_STATE_COOKIE_NAME, build_authorize_url, @@ -592,59 +601,6 @@ async def list_repos( } -def _raise_for_github_repo_status(status_code: int) -> None: - if status_code == 401: - raise HTTPException(401, "github token expired, re-login required") - if status_code == 404: - raise HTTPException(404, "repository not found") - if status_code == 403: - raise HTTPException(403, "no access to this private repository") - if status_code != 200: - raise HTTPException(502, f"github API error ({status_code})") - - -async def _assert_repo_available_for_style_analysis(full_name: str, token: str) -> None: - """Ensure the repo exists and is readable for style learning. - - Public repositories are allowed without the GitHub App installed on them. - Private repositories require the authenticated user to have read access. - """ - full_name = normalize_repo_full_name(full_name) - headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - } - owner, name = full_name.split("/", 1) - async with httpx.AsyncClient() as client: - r = await client.get( - f"https://api.github.com/repos/{owner}/{name}", - headers=headers, - ) - _raise_for_github_repo_status(r.status_code) - body = r.json() - if body.get("private") is not True: - return - # Private repo: 200 from GitHub implies the user's token can read it. - - -async def _require_repo_access_for_user(login: str, full_name: str) -> str: - """Verify the user can read ``full_name`` on GitHub; return a valid access token.""" - token = await get_valid_access_token(login) - if not token: - raise HTTPException(401, "github token unavailable, re-login required") - try: - await _assert_repo_available_for_style_analysis(full_name, token) - except HTTPException as exc: - if exc.status_code != 401: - raise - token = await get_valid_access_token(login, force_refresh=True) - if not token: - raise HTTPException(401, "github token expired, re-login required") from exc - await _assert_repo_available_for_style_analysis(full_name, token) - return token - - @router.get("/review-styles") async def api_list_review_styles( session: dict[str, Any] = _SESSION_DEP, @@ -665,7 +621,7 @@ async def api_create_review_style( body: ReviewStyleCreate, session: dict[str, Any] = _SESSION_DEP, ) -> dict[str, Any]: - await _require_repo_access_for_user(session["sub"], body.full_name) + await require_repo_access_for_user(session["sub"], body.full_name) return await create_review_style(body.full_name, session["sub"]) @@ -693,7 +649,7 @@ async def api_update_review_style_prompt( record = await get_review_style(full_name) if not record: raise HTTPException(404, "review style not found") - await _require_repo_access_for_user(session["sub"], full_name) + await require_repo_access_for_user(session["sub"], full_name) return await set_custom_prompt(full_name, body.custom_prompt) @@ -703,7 +659,7 @@ async def api_analyze_review_style( session: dict[str, Any] = _SESSION_DEP, ) -> dict[str, Any]: full_name = normalize_repo_full_name(full_name) - token = await _require_repo_access_for_user(session["sub"], full_name) + token = await require_repo_access_for_user(session["sub"], full_name) record = await get_review_style(full_name) if not record: record = await create_review_style(full_name, session["sub"]) @@ -762,6 +718,41 @@ async def api_agent_usage_leaderboard( ) +@router.get("/schedules") +async def api_list_schedules( + session: dict[str, Any] = _SESSION_DEP, +) -> list[dict[str, Any]]: + return await list_agent_schedules(session["sub"], email=session.get("email")) + + +@router.post("/schedules") +async def api_create_schedule( + body: ScheduleCreateBody, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + return await create_agent_schedule(session["sub"], body, email=session.get("email")) + + +@router.patch("/schedules/{schedule_id}") +async def api_update_schedule( + schedule_id: str, + body: ScheduleUpdateBody, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + return await update_agent_schedule( + schedule_id, session["sub"], body, email=session.get("email") + ) + + +@router.delete("/schedules/{schedule_id}") +async def api_delete_schedule( + schedule_id: str, + session: dict[str, Any] = _SESSION_DEP, +) -> Response: + await delete_agent_schedule(schedule_id, session["sub"], email=session.get("email")) + return Response(status_code=204) + + @router.get("/threads") async def api_list_threads( session: dict[str, Any] = _SESSION_DEP, diff --git a/agent/dashboard/schedules.py b/agent/dashboard/schedules.py new file mode 100644 index 00000000..5d45b821 --- /dev/null +++ b/agent/dashboard/schedules.py @@ -0,0 +1,467 @@ +"""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, + } diff --git a/agent/dashboard/thread_api.py b/agent/dashboard/thread_api.py index 8d17b89f..e8120758 100644 --- a/agent/dashboard/thread_api.py +++ b/agent/dashboard/thread_api.py @@ -26,7 +26,7 @@ _ASSISTANT_ID = "agent" _DASHBOARD_SOURCE = "dashboard" _DASHBOARD_STREAM_MODES: tuple[str, ...] = ("values", "updates", "messages-tuple") # Sources whose threads should surface in the Agents UI (besides "dashboard"). -_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear") +_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear", "schedule") def _agent_version_metadata() -> dict[str, str]: diff --git a/agent/scheduler.py b/agent/scheduler.py new file mode 100644 index 00000000..7b925f5e --- /dev/null +++ b/agent/scheduler.py @@ -0,0 +1,35 @@ +"""LangGraph entrypoint that fans cron ticks into fresh agent threads.""" + +from __future__ import annotations + +import logging +from typing import Any, TypedDict + +from langgraph.graph import END, START, StateGraph +from langgraph.graph.state import RunnableConfig + +from .dashboard.schedules import launch_scheduled_agent_run + +logger = logging.getLogger(__name__) + + +class SchedulerState(TypedDict, total=False): + schedule_id: str + result: dict[str, Any] + + +async def _launch(state: SchedulerState, config: RunnableConfig) -> dict[str, Any]: + configurable = config.get("configurable") or {} + schedule_id = state.get("schedule_id") or configurable.get("schedule_id") + if not isinstance(schedule_id, str) or not schedule_id: + logger.warning("Scheduled agent tick missing schedule_id") + return {"result": {"status": "missing_schedule_id"}} + return {"result": await launch_scheduled_agent_run(schedule_id)} + + +def get_scheduler(config: RunnableConfig | None = None): + builder = StateGraph(SchedulerState) + builder.add_node("launch", _launch) + builder.add_edge(START, "launch") + builder.add_edge("launch", END) + return builder.compile().with_config(config or {}) diff --git a/agent/utils/auth.py b/agent/utils/auth.py index 6e3958bb..b279e730 100644 --- a/agent/utils/auth.py +++ b/agent/utils/auth.py @@ -421,7 +421,11 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[ # Per-user OAuth from the dashboard store wins even in bot-token-only mode, # for sources that carry a mapped GitHub login (Slack, dashboard). This is # what lets the agent open PRs as the triggering user. - if source in ("slack", "dashboard") and isinstance(github_login, str) and github_login.strip(): + if ( + source in ("slack", "dashboard", "schedule") + and isinstance(github_login, str) + and github_login.strip() + ): try: user_token = await _resolve_dashboard_user_token(thread_id, github_login) except ValueError as exc: diff --git a/langgraph.json b/langgraph.json index c8d4dccd..068c2db6 100644 --- a/langgraph.json +++ b/langgraph.json @@ -4,7 +4,8 @@ "graphs": { "agent": "agent.server:get_agent", "reviewer": "agent.reviewer:get_reviewer_agent", - "analyzer": "agent.analyzer:get_analyzer" + "analyzer": "agent.analyzer:get_analyzer", + "scheduler": "agent.scheduler:get_scheduler" }, "dependencies": ["."], "http": { diff --git a/tests/test_agent_schedules.py b/tests/test_agent_schedules.py new file mode 100644 index 00000000..6aa2a5de --- /dev/null +++ b/tests/test_agent_schedules.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from fastapi import HTTPException +from pydantic import ValidationError + +from agent.dashboard import schedules +from agent.dashboard.schedules import ScheduleCreateBody, ScheduleUpdateBody + + +class _FakeStore: + def __init__(self) -> None: + self.items: dict[tuple[tuple[str, ...], str], dict[str, Any]] = {} + self.deleted: list[tuple[tuple[str, ...], str]] = [] + + async def get_item(self, namespace: list[str], key: str) -> dict[str, Any] | None: + value = self.items.get((tuple(namespace), key)) + return {"value": value} if value is not None else None + + async def put_item(self, namespace: list[str], key: str, value: dict[str, Any]) -> None: + self.items[(tuple(namespace), key)] = value + + async def delete_item(self, namespace: list[str], key: str) -> None: + self.deleted.append((tuple(namespace), key)) + self.items.pop((tuple(namespace), key), None) + + async def search_items( + self, + namespace: list[str], + filter: dict[str, Any] | None = None, + limit: int = 1000, + offset: int = 0, + ) -> dict[str, Any]: + values = [ + value + for (stored_namespace, _), value in self.items.items() + if stored_namespace == tuple(namespace) + ] + if filter: + values = [ + value + for value in values + if all(value.get(key) == expected for key, expected in filter.items()) + ] + return {"items": [{"value": value} for value in values[offset : offset + limit]]} + + +class _FakeCrons: + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + self.deleted: list[str] = [] + + async def create(self, assistant_id: str, **kwargs: Any) -> dict[str, Any]: + self.created.append({"assistant_id": assistant_id, **kwargs}) + return {"cron_id": f"cron_{len(self.created)}"} + + async def delete(self, cron_id: str) -> None: + self.deleted.append(cron_id) + + +class _FakeThreads: + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + self.updated: list[dict[str, Any]] = [] + + async def create(self, **kwargs: Any) -> None: + self.created.append(kwargs) + + async def update(self, **kwargs: Any) -> None: + self.updated.append(kwargs) + + +class _FakeRuns: + def __init__(self) -> None: + self.created: list[dict[str, Any]] = [] + + async def create(self, thread_id: str, assistant_id: str, **kwargs: Any) -> dict[str, Any]: + self.created.append({"thread_id": thread_id, "assistant_id": assistant_id, **kwargs}) + return {"run_id": "run_123"} + + +class _FakeClient: + def __init__(self) -> None: + self.store = _FakeStore() + self.crons = _FakeCrons() + self.threads = _FakeThreads() + self.runs = _FakeRuns() + + +@pytest.fixture +def fake_client(monkeypatch) -> _FakeClient: # noqa: ANN001 + client = _FakeClient() + monkeypatch.setattr(schedules, "_client", lambda: client) + return client + + +@pytest.fixture +def auth(monkeypatch) -> None: # noqa: ANN001 + async def fake_get_valid_access_token(login: str) -> str: + return "gho_token" + + async def fake_get_profile(login: str) -> dict[str, Any]: + return {"base_branch": "main", "branch_prefix": "open-swe"} + + async def fake_resolve_run_email(login: str, profile: dict[str, Any]) -> str: + return "alice@example.com" + + async def fake_repo_config_for_user(login: str, full_name: str | None) -> dict[str, str] | None: + if not full_name: + return None + owner, name = full_name.split("/", 1) + return {"owner": owner, "name": name} + + async def fake_require_repo_access_for_user(login: str, full_name: str) -> str: + return "gho_token" + + monkeypatch.setattr(schedules, "get_valid_access_token", fake_get_valid_access_token) + monkeypatch.setattr(schedules, "get_profile", fake_get_profile) + monkeypatch.setattr(schedules, "_resolve_run_email", fake_resolve_run_email) + monkeypatch.setattr(schedules, "repo_config_for_user", fake_repo_config_for_user) + monkeypatch.setattr( + schedules, "require_repo_access_for_user", fake_require_repo_access_for_user + ) + + +def test_cron_validation_rejects_non_five_field_expression() -> None: + with pytest.raises(ValidationError): + ScheduleCreateBody(prompt="hello", schedule="0 9 * *") + + +def test_cron_validation_accepts_steps_ranges_and_lists() -> None: + body = ScheduleCreateBody(prompt="hello", schedule="*/15 9-17 * * 1,3,5") + + assert body.schedule == "*/15 9-17 * * 1,3,5" + + +async def test_create_agent_schedule_registers_scheduler_cron(fake_client, auth) -> None: # noqa: ANN001, ARG001 + body = ScheduleCreateBody( + name="Daily report", + prompt="Summarize merged PRs", + schedule="0 9 * * 1-5", + repo="langchain-ai/open-swe", + ) + + result = await schedules.create_agent_schedule("alice", body, email="alice@example.com") + + assert result["name"] == "Daily report" + assert result["enabled"] is True + assert result["cronId"] == "cron_1" + created = fake_client.crons.created[0] + assert created["assistant_id"] == "scheduler" + assert created["schedule"] == "0 9 * * 1-5" + assert created["input"]["schedule_id"] == result["id"] + assert created["config"]["configurable"]["schedule_id"] == result["id"] + assert created["metadata"]["kind"] == "agent_schedule" + + +async def test_create_agent_schedule_requires_dashboard_token(fake_client, monkeypatch) -> None: # noqa: ANN001, ARG001 + async def no_token(login: str) -> None: + return None + + monkeypatch.setattr(schedules, "get_valid_access_token", no_token) + + with pytest.raises(HTTPException) as exc: + await schedules.create_agent_schedule( + "alice", ScheduleCreateBody(prompt="hello", schedule="0 9 * * 1") + ) + + assert exc.value.status_code == 401 + assert fake_client.crons.created == [] + + +async def test_create_agent_schedule_requires_repo_access(fake_client, auth, monkeypatch) -> None: # noqa: ANN001, ARG001 + async def deny_repo(login: str, full_name: str | None) -> dict[str, str] | None: + raise HTTPException(403, "no access to this private repository") + + monkeypatch.setattr(schedules, "repo_config_for_user", deny_repo) + + with pytest.raises(HTTPException) as exc: + await schedules.create_agent_schedule( + "alice", + ScheduleCreateBody( + prompt="hello", + schedule="0 9 * * 1", + repo="victim/private", + ), + ) + + assert exc.value.status_code == 403 + assert fake_client.crons.created == [] + + +async def test_list_agent_schedules_uses_owner_filters_and_paginates(fake_client) -> None: # noqa: ANN001 + for i in range(125): + await fake_client.store.put_item( + schedules.SCHEDULES_NAMESPACE, + f"alice_{i}", + { + "id": f"alice_{i}", + "name": f"Alice {i}", + "prompt": "Run daily", + "schedule": "0 9 * * *", + "repo": None, + "model": "Default", + "enabled": True, + "created_by": "alice", + "user_email": "alice@example.com", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": f"2026-01-01T00:{i % 60:02d}:00+00:00", + }, + ) + await fake_client.store.put_item( + schedules.SCHEDULES_NAMESPACE, + "bob_1", + { + "id": "bob_1", + "name": "Bob", + "prompt": "Run daily", + "schedule": "0 9 * * *", + "repo": None, + "model": "Default", + "enabled": True, + "created_by": "bob", + "user_email": "bob@example.com", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + }, + ) + + result = await schedules.list_agent_schedules("alice", email="alice@example.com") + + assert len(result) == 125 + assert {item["id"] for item in result} == {f"alice_{i}" for i in range(125)} + + +async def test_update_agent_schedule_rechecks_repo_access(fake_client, auth, monkeypatch) -> None: # noqa: ANN001, ARG001 + record = { + "id": "sched_1", + "name": "Daily", + "prompt": "Run daily", + "schedule": "0 9 * * *", + "repo": None, + "model": "Default", + "effort": None, + "enabled": True, + "cron_id": "cron_old", + "created_by": "alice", + "user_email": "alice@example.com", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record) + + async def repo_config(login: str, full_name: str | None) -> dict[str, str] | None: + assert full_name == "langchain-ai/open-swe" + return {"owner": "langchain-ai", "name": "open-swe"} + + monkeypatch.setattr(schedules, "repo_config_for_user", repo_config) + + result = await schedules.update_agent_schedule( + "sched_1", + "alice", + ScheduleUpdateBody(repo="langchain-ai/open-swe"), + email="alice@example.com", + ) + + assert result["repo"] == "langchain-ai/open-swe" + + +async def test_update_agent_schedule_pause_deletes_cron(fake_client) -> None: # noqa: ANN001 + record = { + "id": "sched_1", + "name": "Daily", + "prompt": "Run daily", + "schedule": "0 9 * * *", + "repo": None, + "model": "Default", + "effort": None, + "enabled": True, + "cron_id": "cron_old", + "created_by": "alice", + "user_email": "alice@example.com", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record) + + result = await schedules.update_agent_schedule( + "sched_1", "alice", ScheduleUpdateBody(enabled=False), email="alice@example.com" + ) + + assert result["enabled"] is False + assert result["cronId"] is None + assert fake_client.crons.deleted == ["cron_old"] + + +async def test_launch_scheduled_agent_run_skips_when_repo_access_revoked( + fake_client, auth, monkeypatch +) -> None: # noqa: ANN001, ARG001 + record = { + "id": "sched_1", + "name": "Weekly dependencies", + "prompt": "Check dependencies and open a PR if needed", + "schedule": "0 9 * * 1", + "repo": {"owner": "langchain-ai", "name": "open-swe"}, + "model": "Default", + "effort": None, + "base_branch": "main", + "branch_prefix": "open-swe", + "enabled": True, + "cron_id": "cron_1", + "created_by": "alice", + "user_email": "alice@example.com", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record) + + async def deny_access(login: str, full_name: str) -> str: + raise HTTPException(403, "no access to this private repository") + + monkeypatch.setattr(schedules, "require_repo_access_for_user", deny_access) + + result = await schedules.launch_scheduled_agent_run("sched_1") + + assert result == { + "status": "unauthorized", + "schedule_id": "sched_1", + "error": "no access to this private repository", + } + assert fake_client.runs.created == [] + stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), "sched_1")] + assert stored["last_error"] == "no access to this private repository" + + +async def test_launch_scheduled_agent_run_starts_fresh_agent_thread(fake_client, auth) -> None: # noqa: ANN001, ARG001 + record = { + "id": "sched_1", + "name": "Weekly dependencies", + "prompt": "Check dependencies and open a PR if needed", + "schedule": "0 9 * * 1", + "repo": {"owner": "langchain-ai", "name": "open-swe"}, + "model": "Default", + "effort": None, + "base_branch": "main", + "branch_prefix": "open-swe", + "enabled": True, + "cron_id": "cron_1", + "created_by": "alice", + "user_email": "alice@example.com", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + } + await fake_client.store.put_item(schedules.SCHEDULES_NAMESPACE, "sched_1", record) + + result = await schedules.launch_scheduled_agent_run("sched_1") + + assert result["status"] == "started" + thread_id = result["thread_id"] + assert fake_client.threads.created[0]["thread_id"] == thread_id + metadata = fake_client.threads.created[0]["metadata"] + assert metadata["source"] == "schedule" + assert metadata["repo_owner"] == "langchain-ai" + assert metadata["repo_name"] == "open-swe" + run = fake_client.runs.created[0] + assert run["thread_id"] == thread_id + assert run["assistant_id"] == "agent" + assert run["input"]["messages"][0]["content"] == record["prompt"] + assert run["config"]["configurable"]["source"] == "schedule" + assert run["config"]["configurable"]["repo"] == record["repo"] + + stored = fake_client.store.items[(tuple(schedules.SCHEDULES_NAMESPACE), "sched_1")] + assert stored["last_thread_id"] == thread_id + assert stored["last_run_id"] == "run_123" diff --git a/ui/src/components/agents/AgentRunCard.tsx b/ui/src/components/agents/AgentRunCard.tsx index e86d242d..fd2c1abd 100644 --- a/ui/src/components/agents/AgentRunCard.tsx +++ b/ui/src/components/agents/AgentRunCard.tsx @@ -1,36 +1,38 @@ -import { Link } from "@tanstack/react-router"; +import { Link } from "@tanstack/react-router" import { + CalendarBlankIcon, ChatCircleIcon, CheckCircleIcon, GitBranchIcon, GitPullRequestIcon, -} from "@phosphor-icons/react"; -import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"; -import { SiLinear } from "react-icons/si"; -import type { ComponentType, SVGProps } from "react"; +} from "@phosphor-icons/react" +import { IoLogoGithub, IoLogoSlack } from "react-icons/io5" +import { SiLinear } from "react-icons/si" +import type { ComponentType, SVGProps } from "react" -import type { AgentSource, AgentThread } from "@/lib/agents/types"; -import { formatRelativeTime } from "@/lib/agents/api"; -import { cn } from "@/lib/utils"; +import type { AgentSource, AgentThread } from "@/lib/agents/types" +import { formatRelativeTime } from "@/lib/agents/api" +import { cn } from "@/lib/utils" -type SourceIcon = ComponentType>; +type SourceIcon = ComponentType> const SOURCE_META: Record = { dashboard: { icon: ChatCircleIcon, label: "Dashboard" }, github: { icon: IoLogoGithub, label: "GitHub" }, slack: { icon: IoLogoSlack, label: "Slack" }, linear: { icon: SiLinear, label: "Linear" }, -}; + schedule: { icon: CalendarBlankIcon, label: "Schedule" }, +} interface AgentRunCardProps { - thread: AgentThread; + thread: AgentThread } export function AgentRunCard({ thread }: AgentRunCardProps) { - const stats = thread.diffStats; - const hasPr = Boolean(thread.pr); - const source = thread.source ? SOURCE_META[thread.source] : null; - const SourceIcon = source?.icon; + const stats = thread.diffStats + const hasPr = Boolean(thread.pr) + const source = thread.source ? SOURCE_META[thread.source] : null + const SourceIcon = source?.icon return (
- +{stats.additions} - -{stats.deletions} + + +{stats.additions} + + + -{stats.deletions} +
) : ( @@ -68,11 +74,16 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
-
{thread.title}
+
+ {thread.title} +
{source && SourceIcon && ( <> - + {source.label} @@ -91,7 +102,9 @@ export function AgentRunCard({ thread }: AgentRunCardProps) { )} · - {formatRelativeTime(thread.updatedAt)} + + {formatRelativeTime(thread.updatedAt)} +
@@ -100,10 +113,10 @@ export function AgentRunCard({ thread }: AgentRunCardProps) { "size-5 shrink-0", thread.status === "finished" ? "text-[var(--ui-text-dim)] opacity-100" - : "opacity-0", + : "opacity-0" )} weight="regular" /> - ); + ) } diff --git a/ui/src/components/agents/AgentsSidebar.tsx b/ui/src/components/agents/AgentsSidebar.tsx index 75933533..3447a35b 100644 --- a/ui/src/components/agents/AgentsSidebar.tsx +++ b/ui/src/components/agents/AgentsSidebar.tsx @@ -1,50 +1,59 @@ -import { Link } from "@tanstack/react-router"; +import { Link } from "@tanstack/react-router" import { + CalendarBlankIcon, ChartLineUpIcon, ChatCircleIcon, + LightningIcon, PlusIcon, XIcon, -} from "@phosphor-icons/react"; -import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"; -import { SiLinear } from "react-icons/si"; -import type { ComponentType, SVGProps } from "react"; +} from "@phosphor-icons/react" +import { IoLogoGithub, IoLogoSlack } from "react-icons/io5" +import { SiLinear } from "react-icons/si" +import type { ComponentType, SVGProps } from "react" -import type { SessionUser } from "@/lib/api"; -import type { AgentSource, AgentThread } from "@/lib/agents/types"; -import { SidebarUserMenu } from "@/components/SidebarUserMenu"; +import type { SessionUser } from "@/lib/api" +import type { AgentSource, AgentThread } from "@/lib/agents/types" +import { SidebarUserMenu } from "@/components/SidebarUserMenu" import { SidebarCollapseButton, SidebarFrame, useSidebarLayout, -} from "@/components/sidebar-layout"; -import { groupThreads } from "@/lib/agents/api"; -import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries"; -import { cn } from "@/lib/utils"; +} from "@/components/sidebar-layout" +import { groupThreads } from "@/lib/agents/api" +import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries" +import { cn } from "@/lib/utils" -type SourceIcon = ComponentType>; +type SourceIcon = ComponentType> const SOURCE_META: Record = { dashboard: { icon: ChatCircleIcon, label: "Started from the dashboard" }, github: { icon: IoLogoGithub, label: "Triggered from GitHub" }, slack: { icon: IoLogoSlack, label: "Triggered from Slack" }, linear: { icon: SiLinear, label: "Triggered from Linear" }, -}; - -interface AgentsSidebarProps { - user: SessionUser; - activeThreadId?: string; + schedule: { icon: CalendarBlankIcon, label: "Triggered from a schedule" }, } -const NAV = [{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon }] as const; +interface AgentsSidebarProps { + user: SessionUser + activeThreadId?: string +} + +const NAV = [ + { to: "/agents/automations", label: "Automations", icon: LightningIcon }, + { to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon }, +] as const export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) { - const threadsQuery = useAgentThreads(); - const threads = threadsQuery.data ?? []; - const groups = groupThreads(threads); - const layout = useSidebarLayout(); + const threadsQuery = useAgentThreads() + const threads = threadsQuery.data ?? [] + const groups = groupThreads(threads) + const layout = useSidebarLayout() return ( - +
{NAV.map((item) => { - const Icon = item.icon; + const Icon = item.icon return ( {item.label} - ); + ) })} @@ -109,7 +122,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
- ); + ) } function ThreadGroup({ @@ -118,12 +131,12 @@ function ThreadGroup({ activeThreadId, onNavigate, }: { - label: string; - threads: Array; - activeThreadId?: string; - onNavigate?: () => void; + label: string + threads: Array + activeThreadId?: string + onNavigate?: () => void }) { - if (threads.length === 0) return null; + if (threads.length === 0) return null return (
@@ -139,7 +152,7 @@ function ThreadGroup({ /> ))}
- ); + ) } function ThreadRow({ @@ -147,27 +160,32 @@ function ThreadRow({ isActive, onNavigate, }: { - thread: AgentThread; - isActive: boolean; - onNavigate?: () => void; + thread: AgentThread + isActive: boolean + onNavigate?: () => void }) { - const deleteThread = useDeleteAgentThread(); + const deleteThread = useDeleteAgentThread() const badge = thread.diffStats && thread.diffStats.additions > 0 ? `+${thread.diffStats.additions}` - : null; - const isDeleting = deleteThread.isPending && deleteThread.variables === thread.id; + : null + const isDeleting = + deleteThread.isPending && deleteThread.variables === thread.id const onDelete = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (isDeleting) return; - if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`)) return; - deleteThread.mutate(thread.id); - }; + e.preventDefault() + e.stopPropagation() + if (isDeleting) return + if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`)) + return + deleteThread.mutate(thread.id) + } - const source = thread.source && thread.source !== "dashboard" ? SOURCE_META[thread.source] : null; - const SourceIcon = source?.icon; + const source = + thread.source && thread.source !== "dashboard" + ? SOURCE_META[thread.source] + : null + const SourceIcon = source?.icon return ( {source && SourceIcon && ( @@ -211,12 +229,12 @@ function ThreadRow({ aria-label="Delete thread" onClick={onDelete} disabled={isDeleting} - className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] group-hover:flex" + className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]" > - ); + ) } export function AgentsShell({ @@ -224,14 +242,14 @@ export function AgentsShell({ activeThreadId, children, }: { - user: SessionUser; - activeThreadId?: string; - children: React.ReactNode; + user: SessionUser + activeThreadId?: string + children: React.ReactNode }) { return (
{children}
- ); + ) } diff --git a/ui/src/components/agents/AutomationEditor.tsx b/ui/src/components/agents/AutomationEditor.tsx new file mode 100644 index 00000000..a896a9ce --- /dev/null +++ b/ui/src/components/agents/AutomationEditor.tsx @@ -0,0 +1,351 @@ +import { useEffect, useMemo, useRef, useState } from "react" +import { Link, useNavigate } from "@tanstack/react-router" +import { + CaretDownIcon, + CheckIcon, + ClockIcon, + TrashIcon, +} from "@phosphor-icons/react" + +import type { ModelOption } from "@/lib/api" +import type { AgentSchedule } from "@/lib/agents/types" +import type { ModelSelection } from "@/lib/agents/useModelOptions" +import { RepoSelector } from "@/components/agents/RepoSelector" +import { ScheduleTriggerPicker } from "@/components/agents/ScheduleTriggerPicker" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { describeCron, presetForCron } from "@/lib/agents/cron" +import { + useCreateAgentSchedule, + useDeleteAgentSchedule, + useUpdateAgentSchedule, +} from "@/lib/agents/queries" +import { + formatModelSelection, + useModelOptions, +} from "@/lib/agents/useModelOptions" +import { useRepos } from "@/lib/profile" +import { cn } from "@/lib/utils" + +interface AutomationEditorProps { + mode: "create" | "edit" + schedule?: AgentSchedule +} + +function scheduleToSelection( + models: Array, + schedule?: AgentSchedule +): ModelSelection | null { + if (!schedule?.model || !schedule.effort) return null + const supported = models.some( + (model) => + model.id === schedule.model && model.efforts.includes(schedule.effort!) + ) + return supported + ? { modelId: schedule.model, effort: schedule.effort } + : null +} + +export function AutomationEditor({ mode, schedule }: AutomationEditorProps) { + const navigate = useNavigate() + const reposQuery = useRepos() + const { models, defaultSelection } = useModelOptions() + + const createSchedule = useCreateAgentSchedule() + const updateSchedule = useUpdateAgentSchedule() + const deleteSchedule = useDeleteAgentSchedule() + + const [name, setName] = useState(schedule?.name ?? "") + const [prompt, setPrompt] = useState(schedule?.prompt ?? "") + const [cron, setCron] = useState(schedule?.schedule ?? null) + const [customMode, setCustomMode] = useState( + schedule ? presetForCron(schedule.schedule) === "custom" : false + ) + const [repo, setRepo] = useState(schedule?.repo ?? null) + const [enabled, setEnabled] = useState(schedule?.enabled ?? true) + // undefined = untouched (derive from the schedule / default as models load). + const [selectionOverride, setSelectionOverride] = useState< + ModelSelection | null | undefined + >(undefined) + + const activeSelection = + selectionOverride !== undefined + ? selectionOverride + : (scheduleToSelection(models, schedule) ?? defaultSelection) + + const error = + createSchedule.error || updateSchedule.error || deleteSchedule.error + const errorMessage = error instanceof Error ? error.message : null + const isSaving = createSchedule.isPending || updateSchedule.isPending + + const canSave = name.trim().length > 0 && prompt.trim().length > 0 && !!cron + + const onPickTrigger = (value: string | null) => { + if (value === null) { + setCustomMode(true) + setCron((current) => current ?? "0 9 * * *") + } else { + setCustomMode(false) + setCron(value) + } + } + + const handleSave = () => { + if (!canSave || !cron) return + const modelIsReal = models.some((m) => m.id === activeSelection?.modelId) + const modelId = modelIsReal ? (activeSelection?.modelId ?? null) : null + const effort = modelIsReal ? (activeSelection?.effort ?? null) : null + + if (mode === "create") { + createSchedule.mutate( + { + name: name.trim(), + prompt: prompt.trim(), + schedule: cron.trim(), + repo, + model_id: modelId, + effort, + }, + { onSuccess: () => navigate({ to: "/agents/automations" }) } + ) + return + } + if (!schedule) return + updateSchedule.mutate( + { + scheduleId: schedule.id, + body: { + name: name.trim(), + prompt: prompt.trim(), + schedule: cron.trim(), + repo: repo ?? "", + model_id: modelId, + effort, + enabled, + }, + }, + { onSuccess: () => navigate({ to: "/agents/automations" }) } + ) + } + + const handleDelete = () => { + if (!schedule) return + if (!window.confirm(`Delete "${schedule.name}"?`)) return + deleteSchedule.mutate(schedule.id, { + onSuccess: () => navigate({ to: "/agents/automations" }), + }) + } + + return ( +
+
+
+ + Automations + + / + + {name.trim() || "New automation"} + +
+
+ {mode === "edit" && ( + + )} + +
+
+ +
+ setName(e.target.value)} + placeholder="Untitled automation" + className="w-full bg-transparent text-2xl font-semibold text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]" + /> + +
+
+ + + {enabled ? "Active" : "Paused"} + +
+ | + +
+ + Triggers +
+ {cron && ( +
+ + {customMode ? ( + setCron(e.target.value)} + placeholder="0 9 * * 1-5" + className="flex-1 bg-transparent font-mono text-sm text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]" + /> + ) : ( + + {describeCron(cron)} + + )} + +
+ )} + {cron &&
} + +
+ + Agent Instructions +
+