mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-04 23:02:12 +00:00
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>
This commit is contained in:
parent
78ca5af7f4
commit
6895ddcedc
21 changed files with 2288 additions and 327 deletions
65
agent/dashboard/repo_access.py
Normal file
65
agent/dashboard/repo_access.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -43,6 +43,7 @@ from .profiles import (
|
||||||
upsert_access_token_from_github_response,
|
upsert_access_token_from_github_response,
|
||||||
upsert_profile,
|
upsert_profile,
|
||||||
)
|
)
|
||||||
|
from .repo_access import require_repo_access_for_user
|
||||||
from .review_style_jobs import (
|
from .review_style_jobs import (
|
||||||
cancel_review_style_analysis,
|
cancel_review_style_analysis,
|
||||||
start_bootstrap_analysis,
|
start_bootstrap_analysis,
|
||||||
|
|
@ -58,6 +59,14 @@ from .review_styles import (
|
||||||
normalize_repo_full_name,
|
normalize_repo_full_name,
|
||||||
set_custom_prompt,
|
set_custom_prompt,
|
||||||
)
|
)
|
||||||
|
from .schedules import (
|
||||||
|
ScheduleCreateBody,
|
||||||
|
ScheduleUpdateBody,
|
||||||
|
create_agent_schedule,
|
||||||
|
delete_agent_schedule,
|
||||||
|
list_agent_schedules,
|
||||||
|
update_agent_schedule,
|
||||||
|
)
|
||||||
from .slack_oauth import (
|
from .slack_oauth import (
|
||||||
SLACK_STATE_COOKIE_NAME,
|
SLACK_STATE_COOKIE_NAME,
|
||||||
build_authorize_url,
|
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")
|
@router.get("/review-styles")
|
||||||
async def api_list_review_styles(
|
async def api_list_review_styles(
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
|
@ -665,7 +621,7 @@ async def api_create_review_style(
|
||||||
body: ReviewStyleCreate,
|
body: ReviewStyleCreate,
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> dict[str, Any]:
|
) -> 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"])
|
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)
|
record = await get_review_style(full_name)
|
||||||
if not record:
|
if not record:
|
||||||
raise HTTPException(404, "review style not found")
|
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)
|
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,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
full_name = normalize_repo_full_name(full_name)
|
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)
|
record = await get_review_style(full_name)
|
||||||
if not record:
|
if not record:
|
||||||
record = await create_review_style(full_name, session["sub"])
|
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")
|
@router.get("/threads")
|
||||||
async def api_list_threads(
|
async def api_list_threads(
|
||||||
session: dict[str, Any] = _SESSION_DEP,
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
|
|
||||||
467
agent/dashboard/schedules.py
Normal file
467
agent/dashboard/schedules.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -26,7 +26,7 @@ _ASSISTANT_ID = "agent"
|
||||||
_DASHBOARD_SOURCE = "dashboard"
|
_DASHBOARD_SOURCE = "dashboard"
|
||||||
_DASHBOARD_STREAM_MODES: tuple[str, ...] = ("values", "updates", "messages-tuple")
|
_DASHBOARD_STREAM_MODES: tuple[str, ...] = ("values", "updates", "messages-tuple")
|
||||||
# Sources whose threads should surface in the Agents UI (besides "dashboard").
|
# 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]:
|
def _agent_version_metadata() -> dict[str, str]:
|
||||||
|
|
|
||||||
35
agent/scheduler.py
Normal file
35
agent/scheduler.py
Normal file
|
|
@ -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 {})
|
||||||
|
|
@ -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,
|
# 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
|
# for sources that carry a mapped GitHub login (Slack, dashboard). This is
|
||||||
# what lets the agent open PRs as the triggering user.
|
# 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:
|
try:
|
||||||
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
|
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
"graphs": {
|
"graphs": {
|
||||||
"agent": "agent.server:get_agent",
|
"agent": "agent.server:get_agent",
|
||||||
"reviewer": "agent.reviewer:get_reviewer_agent",
|
"reviewer": "agent.reviewer:get_reviewer_agent",
|
||||||
"analyzer": "agent.analyzer:get_analyzer"
|
"analyzer": "agent.analyzer:get_analyzer",
|
||||||
|
"scheduler": "agent.scheduler:get_scheduler"
|
||||||
},
|
},
|
||||||
"dependencies": ["."],
|
"dependencies": ["."],
|
||||||
"http": {
|
"http": {
|
||||||
|
|
|
||||||
376
tests/test_agent_schedules.py
Normal file
376
tests/test_agent_schedules.py
Normal file
|
|
@ -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"
|
||||||
|
|
@ -1,36 +1,38 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router"
|
||||||
import {
|
import {
|
||||||
|
CalendarBlankIcon,
|
||||||
ChatCircleIcon,
|
ChatCircleIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
GitBranchIcon,
|
GitBranchIcon,
|
||||||
GitPullRequestIcon,
|
GitPullRequestIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react"
|
||||||
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5";
|
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
|
||||||
import { SiLinear } from "react-icons/si";
|
import { SiLinear } from "react-icons/si"
|
||||||
import type { ComponentType, SVGProps } from "react";
|
import type { ComponentType, SVGProps } from "react"
|
||||||
|
|
||||||
import type { AgentSource, AgentThread } from "@/lib/agents/types";
|
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||||
import { formatRelativeTime } from "@/lib/agents/api";
|
import { formatRelativeTime } from "@/lib/agents/api"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
||||||
dashboard: { icon: ChatCircleIcon, label: "Dashboard" },
|
dashboard: { icon: ChatCircleIcon, label: "Dashboard" },
|
||||||
github: { icon: IoLogoGithub, label: "GitHub" },
|
github: { icon: IoLogoGithub, label: "GitHub" },
|
||||||
slack: { icon: IoLogoSlack, label: "Slack" },
|
slack: { icon: IoLogoSlack, label: "Slack" },
|
||||||
linear: { icon: SiLinear, label: "Linear" },
|
linear: { icon: SiLinear, label: "Linear" },
|
||||||
};
|
schedule: { icon: CalendarBlankIcon, label: "Schedule" },
|
||||||
|
}
|
||||||
|
|
||||||
interface AgentRunCardProps {
|
interface AgentRunCardProps {
|
||||||
thread: AgentThread;
|
thread: AgentThread
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentRunCard({ thread }: AgentRunCardProps) {
|
export function AgentRunCard({ thread }: AgentRunCardProps) {
|
||||||
const stats = thread.diffStats;
|
const stats = thread.diffStats
|
||||||
const hasPr = Boolean(thread.pr);
|
const hasPr = Boolean(thread.pr)
|
||||||
const source = thread.source ? SOURCE_META[thread.source] : null;
|
const source = thread.source ? SOURCE_META[thread.source] : null
|
||||||
const SourceIcon = source?.icon;
|
const SourceIcon = source?.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -45,8 +47,12 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
||||||
{stats.files} {stats.files === 1 ? "file" : "files"}
|
{stats.files} {stats.files === 1 ? "file" : "files"}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs font-medium">
|
<div className="mt-0.5 flex items-center gap-1.5 text-xs font-medium">
|
||||||
<span className="text-[var(--ui-success)]">+{stats.additions}</span>
|
<span className="text-[var(--ui-success)]">
|
||||||
<span className="text-[var(--ui-danger)]">-{stats.deletions}</span>
|
+{stats.additions}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--ui-danger)]">
|
||||||
|
-{stats.deletions}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -68,11 +74,16 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-sm font-medium text-[var(--ui-text)]">{thread.title}</div>
|
<div className="truncate text-sm font-medium text-[var(--ui-text)]">
|
||||||
|
{thread.title}
|
||||||
|
</div>
|
||||||
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-[var(--ui-text-dim)]">
|
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-[var(--ui-text-dim)]">
|
||||||
{source && SourceIcon && (
|
{source && SourceIcon && (
|
||||||
<>
|
<>
|
||||||
<span className="flex shrink-0 items-center gap-1" title={source.label}>
|
<span
|
||||||
|
className="flex shrink-0 items-center gap-1"
|
||||||
|
title={source.label}
|
||||||
|
>
|
||||||
<SourceIcon className="size-3.5" aria-label={source.label} />
|
<SourceIcon className="size-3.5" aria-label={source.label} />
|
||||||
{source.label}
|
{source.label}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -91,7 +102,9 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="shrink-0">·</span>
|
<span className="shrink-0">·</span>
|
||||||
<span className="shrink-0 whitespace-nowrap">{formatRelativeTime(thread.updatedAt)}</span>
|
<span className="shrink-0 whitespace-nowrap">
|
||||||
|
{formatRelativeTime(thread.updatedAt)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -100,10 +113,10 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
||||||
"size-5 shrink-0",
|
"size-5 shrink-0",
|
||||||
thread.status === "finished"
|
thread.status === "finished"
|
||||||
? "text-[var(--ui-text-dim)] opacity-100"
|
? "text-[var(--ui-text-dim)] opacity-100"
|
||||||
: "opacity-0",
|
: "opacity-0"
|
||||||
)}
|
)}
|
||||||
weight="regular"
|
weight="regular"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,59 @@
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router"
|
||||||
import {
|
import {
|
||||||
|
CalendarBlankIcon,
|
||||||
ChartLineUpIcon,
|
ChartLineUpIcon,
|
||||||
ChatCircleIcon,
|
ChatCircleIcon,
|
||||||
|
LightningIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react"
|
||||||
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5";
|
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
|
||||||
import { SiLinear } from "react-icons/si";
|
import { SiLinear } from "react-icons/si"
|
||||||
import type { ComponentType, SVGProps } from "react";
|
import type { ComponentType, SVGProps } from "react"
|
||||||
|
|
||||||
import type { SessionUser } from "@/lib/api";
|
import type { SessionUser } from "@/lib/api"
|
||||||
import type { AgentSource, AgentThread } from "@/lib/agents/types";
|
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu";
|
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||||
import {
|
import {
|
||||||
SidebarCollapseButton,
|
SidebarCollapseButton,
|
||||||
SidebarFrame,
|
SidebarFrame,
|
||||||
useSidebarLayout,
|
useSidebarLayout,
|
||||||
} from "@/components/sidebar-layout";
|
} from "@/components/sidebar-layout"
|
||||||
import { groupThreads } from "@/lib/agents/api";
|
import { groupThreads } from "@/lib/agents/api"
|
||||||
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries";
|
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries"
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||||
|
|
||||||
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
||||||
dashboard: { icon: ChatCircleIcon, label: "Started from the dashboard" },
|
dashboard: { icon: ChatCircleIcon, label: "Started from the dashboard" },
|
||||||
github: { icon: IoLogoGithub, label: "Triggered from GitHub" },
|
github: { icon: IoLogoGithub, label: "Triggered from GitHub" },
|
||||||
slack: { icon: IoLogoSlack, label: "Triggered from Slack" },
|
slack: { icon: IoLogoSlack, label: "Triggered from Slack" },
|
||||||
linear: { icon: SiLinear, label: "Triggered from Linear" },
|
linear: { icon: SiLinear, label: "Triggered from Linear" },
|
||||||
};
|
schedule: { icon: CalendarBlankIcon, label: "Triggered from a schedule" },
|
||||||
|
|
||||||
interface AgentsSidebarProps {
|
|
||||||
user: SessionUser;
|
|
||||||
activeThreadId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||||
const threadsQuery = useAgentThreads();
|
const threadsQuery = useAgentThreads()
|
||||||
const threads = threadsQuery.data ?? [];
|
const threads = threadsQuery.data ?? []
|
||||||
const groups = groupThreads(threads);
|
const groups = groupThreads(threads)
|
||||||
const layout = useSidebarLayout();
|
const layout = useSidebarLayout()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarFrame {...layout} className="border-r border-[var(--ui-border)] bg-[var(--ui-sidebar)]">
|
<SidebarFrame
|
||||||
|
{...layout}
|
||||||
|
className="border-r border-[var(--ui-border)] bg-[var(--ui-sidebar)]"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-4 pt-5 pb-4">
|
<div className="flex items-center justify-between px-4 pt-5 pb-4">
|
||||||
<Link
|
<Link
|
||||||
to="/my-settings"
|
to="/my-settings"
|
||||||
|
|
@ -69,18 +78,22 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||||
|
|
||||||
<nav className="flex flex-col gap-0.5 px-2 pb-4">
|
<nav className="flex flex-col gap-0.5 px-2 pb-4">
|
||||||
{NAV.map((item) => {
|
{NAV.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
onClick={layout.closeOnMobile}
|
onClick={layout.closeOnMobile}
|
||||||
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]"
|
className="flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-sidebar-hover)] hover:text-[var(--ui-text)]"
|
||||||
|
activeProps={{
|
||||||
|
className:
|
||||||
|
"bg-[var(--ui-sidebar-hover)] !text-[var(--ui-text)] font-medium",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -109,7 +122,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||||
<SidebarUserMenu user={user} showSettingsLink />
|
<SidebarUserMenu user={user} showSettingsLink />
|
||||||
</div>
|
</div>
|
||||||
</SidebarFrame>
|
</SidebarFrame>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThreadGroup({
|
function ThreadGroup({
|
||||||
|
|
@ -118,12 +131,12 @@ function ThreadGroup({
|
||||||
activeThreadId,
|
activeThreadId,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string
|
||||||
threads: Array<AgentThread>;
|
threads: Array<AgentThread>
|
||||||
activeThreadId?: string;
|
activeThreadId?: string
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void
|
||||||
}) {
|
}) {
|
||||||
if (threads.length === 0) return null;
|
if (threads.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
|
|
@ -139,7 +152,7 @@ function ThreadGroup({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThreadRow({
|
function ThreadRow({
|
||||||
|
|
@ -147,27 +160,32 @@ function ThreadRow({
|
||||||
isActive,
|
isActive,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
thread: AgentThread;
|
thread: AgentThread
|
||||||
isActive: boolean;
|
isActive: boolean
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void
|
||||||
}) {
|
}) {
|
||||||
const deleteThread = useDeleteAgentThread();
|
const deleteThread = useDeleteAgentThread()
|
||||||
const badge =
|
const badge =
|
||||||
thread.diffStats && thread.diffStats.additions > 0
|
thread.diffStats && thread.diffStats.additions > 0
|
||||||
? `+${thread.diffStats.additions}`
|
? `+${thread.diffStats.additions}`
|
||||||
: null;
|
: null
|
||||||
const isDeleting = deleteThread.isPending && deleteThread.variables === thread.id;
|
const isDeleting =
|
||||||
|
deleteThread.isPending && deleteThread.variables === thread.id
|
||||||
|
|
||||||
const onDelete = (e: React.MouseEvent) => {
|
const onDelete = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
e.stopPropagation();
|
e.stopPropagation()
|
||||||
if (isDeleting) return;
|
if (isDeleting) return
|
||||||
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`)) return;
|
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`))
|
||||||
deleteThread.mutate(thread.id);
|
return
|
||||||
};
|
deleteThread.mutate(thread.id)
|
||||||
|
}
|
||||||
|
|
||||||
const source = thread.source && thread.source !== "dashboard" ? SOURCE_META[thread.source] : null;
|
const source =
|
||||||
const SourceIcon = source?.icon;
|
thread.source && thread.source !== "dashboard"
|
||||||
|
? SOURCE_META[thread.source]
|
||||||
|
: null
|
||||||
|
const SourceIcon = source?.icon
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -179,7 +197,7 @@ function ThreadRow({
|
||||||
isActive
|
isActive
|
||||||
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
||||||
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
|
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
|
||||||
isDeleting && "opacity-50",
|
isDeleting && "opacity-50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
@ -189,7 +207,7 @@ function ThreadRow({
|
||||||
? "animate-pulse bg-[var(--ui-accent)]"
|
? "animate-pulse bg-[var(--ui-accent)]"
|
||||||
: thread.status === "finished"
|
: thread.status === "finished"
|
||||||
? "bg-[var(--ui-accent)]"
|
? "bg-[var(--ui-accent)]"
|
||||||
: "bg-[var(--ui-border)]",
|
: "bg-[var(--ui-border)]"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{source && SourceIcon && (
|
{source && SourceIcon && (
|
||||||
|
|
@ -211,12 +229,12 @@ function ThreadRow({
|
||||||
aria-label="Delete thread"
|
aria-label="Delete thread"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={isDeleting}
|
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)]"
|
||||||
>
|
>
|
||||||
<XIcon className="size-3" weight="bold" />
|
<XIcon className="size-3" weight="bold" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentsShell({
|
export function AgentsShell({
|
||||||
|
|
@ -224,14 +242,14 @@ export function AgentsShell({
|
||||||
activeThreadId,
|
activeThreadId,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
user: SessionUser;
|
user: SessionUser
|
||||||
activeThreadId?: string;
|
activeThreadId?: string
|
||||||
children: React.ReactNode;
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
|
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
|
||||||
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
|
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
|
||||||
<div className="flex min-w-0 flex-1">{children}</div>
|
<div className="flex min-w-0 flex-1">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
351
ui/src/components/agents/AutomationEditor.tsx
Normal file
351
ui/src/components/agents/AutomationEditor.tsx
Normal file
|
|
@ -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<ModelOption>,
|
||||||
|
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<string | null>(schedule?.schedule ?? null)
|
||||||
|
const [customMode, setCustomMode] = useState(
|
||||||
|
schedule ? presetForCron(schedule.schedule) === "custom" : false
|
||||||
|
)
|
||||||
|
const [repo, setRepo] = useState<string | null>(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 (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
|
||||||
|
<header className="flex items-center justify-between gap-3 px-6 py-4 max-md:pt-14">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 text-sm text-[var(--ui-text-dim)]">
|
||||||
|
<Link
|
||||||
|
to="/agents/automations"
|
||||||
|
className="shrink-0 transition-colors hover:text-[var(--ui-text)]"
|
||||||
|
>
|
||||||
|
Automations
|
||||||
|
</Link>
|
||||||
|
<span className="shrink-0">/</span>
|
||||||
|
<span className="truncate text-[var(--ui-text)]">
|
||||||
|
{name.trim() || "New automation"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{mode === "edit" && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleteSchedule.isPending}
|
||||||
|
aria-label="Delete automation"
|
||||||
|
className="text-[var(--ui-text-dim)] hover:text-[var(--ui-danger)]"
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSave} disabled={!canSave || isSaving}>
|
||||||
|
{isSaving
|
||||||
|
? "Saving…"
|
||||||
|
: mode === "create"
|
||||||
|
? "Create"
|
||||||
|
: "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-3xl px-6 pt-2 pb-16">
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||||
|
<span className="text-[var(--ui-text-muted)]">
|
||||||
|
{enabled ? "Active" : "Paused"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[var(--ui-border)]">|</span>
|
||||||
|
<RepoSelector
|
||||||
|
repos={reposQuery.data?.repositories}
|
||||||
|
selectedRepo={repo}
|
||||||
|
onRepoChange={setRepo}
|
||||||
|
placeholder="No repository"
|
||||||
|
triggerClassName="text-[var(--ui-text-muted)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionLabel>Triggers</SectionLabel>
|
||||||
|
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] p-1.5">
|
||||||
|
{cron && (
|
||||||
|
<div className="flex items-center gap-3 rounded-lg px-3 py-2.5">
|
||||||
|
<ClockIcon className="size-4 shrink-0 text-[var(--ui-text-muted)]" />
|
||||||
|
{customMode ? (
|
||||||
|
<input
|
||||||
|
value={cron}
|
||||||
|
onChange={(e) => 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)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 text-sm text-[var(--ui-text)]">
|
||||||
|
{describeCron(cron)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCron(null)
|
||||||
|
setCustomMode(false)
|
||||||
|
}}
|
||||||
|
aria-label="Remove trigger"
|
||||||
|
className="shrink-0 rounded p-1 text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cron && <div className="mx-3 h-px bg-[var(--ui-border-subtle)]" />}
|
||||||
|
<ScheduleTriggerPicker
|
||||||
|
onSelect={onPickTrigger}
|
||||||
|
triggerLabel={cron ? "Change trigger" : "Add Trigger"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionLabel>Agent Instructions</SectionLabel>
|
||||||
|
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] p-3">
|
||||||
|
<textarea
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
placeholder="What should Open SWE do each time this runs?"
|
||||||
|
rows={5}
|
||||||
|
className="w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--ui-text)] outline-none placeholder:text-[var(--ui-text-dim)]"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 flex items-center">
|
||||||
|
<ModelPicker
|
||||||
|
models={models}
|
||||||
|
selection={activeSelection}
|
||||||
|
onSelectionChange={setSelectionOverride}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="mt-4 text-sm text-[var(--ui-danger)]">{errorMessage}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<h2 className="mt-8 mb-2 text-sm font-medium text-[var(--ui-text-muted)]">
|
||||||
|
{children}
|
||||||
|
</h2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelPicker({
|
||||||
|
models,
|
||||||
|
selection,
|
||||||
|
onSelectionChange,
|
||||||
|
}: {
|
||||||
|
models: Array<ModelOption>
|
||||||
|
selection: ModelSelection | null
|
||||||
|
onSelectionChange: (next: ModelSelection) => void
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const combos = useMemo<Array<ModelSelection>>(() => {
|
||||||
|
const list: Array<ModelSelection> = []
|
||||||
|
for (const model of models) {
|
||||||
|
for (const effort of model.efforts) {
|
||||||
|
list.push({ modelId: model.id, effort })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}, [models])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const disabled = combos.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className="flex items-center gap-1 text-sm text-[var(--ui-text-muted)] transition-opacity hover:opacity-80 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span>{formatModelSelection(models, selection)}</span>
|
||||||
|
{!disabled && <CaretDownIcon className="size-3.5 opacity-60" />}
|
||||||
|
</button>
|
||||||
|
{open && combos.length > 0 && (
|
||||||
|
<div className="absolute bottom-full left-0 z-50 mb-1 max-h-72 overflow-y-auto rounded-lg border border-[var(--ui-border)] bg-[var(--ui-surface)] py-1 shadow-lg">
|
||||||
|
{combos.map((combo) => {
|
||||||
|
const selected =
|
||||||
|
!!selection &&
|
||||||
|
selection.modelId === combo.modelId &&
|
||||||
|
selection.effort === combo.effort
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${combo.modelId}::${combo.effort}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectionChange(combo)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm whitespace-nowrap transition-colors hover:bg-[var(--ui-panel-2)]",
|
||||||
|
selected
|
||||||
|
? "text-[var(--ui-text)]"
|
||||||
|
: "text-[var(--ui-text-muted)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatModelSelection(models, combo)}
|
||||||
|
{selected && (
|
||||||
|
<CheckIcon className="ml-auto size-3.5 text-[var(--ui-text-dim)]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
204
ui/src/components/agents/AutomationsList.tsx
Normal file
204
ui/src/components/agents/AutomationsList.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import {
|
||||||
|
ClockIcon,
|
||||||
|
LightningIcon,
|
||||||
|
PauseIcon,
|
||||||
|
PlayIcon,
|
||||||
|
PlusIcon,
|
||||||
|
WarningCircleIcon,
|
||||||
|
} from "@phosphor-icons/react"
|
||||||
|
|
||||||
|
import type { AgentSchedule } from "@/lib/agents/types"
|
||||||
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { describeCron } from "@/lib/agents/cron"
|
||||||
|
import {
|
||||||
|
useAgentSchedules,
|
||||||
|
useUpdateAgentSchedule,
|
||||||
|
} from "@/lib/agents/queries"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function formatDate(value?: string | null): string {
|
||||||
|
if (!value) return "Never run"
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return "Never run"
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutomationsList() {
|
||||||
|
const schedulesQuery = useAgentSchedules()
|
||||||
|
const schedules = schedulesQuery.data ?? []
|
||||||
|
|
||||||
|
const total = schedules.length
|
||||||
|
const active = schedules.filter((s) => s.enabled).length
|
||||||
|
const paused = total - active
|
||||||
|
const issues = schedules.filter((s) => !!s.lastError).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col overflow-y-auto">
|
||||||
|
<div className="mx-auto w-full max-w-4xl px-6 py-8 max-md:pt-16">
|
||||||
|
<h1 className="text-2xl font-semibold text-[var(--ui-text)]">
|
||||||
|
Automations
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--ui-text-muted)]">
|
||||||
|
Run Open SWE on a recurring schedule. Each run starts a fresh agent
|
||||||
|
thread.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard label="Total" value={total} />
|
||||||
|
<StatCard label="Active" value={active} />
|
||||||
|
<StatCard label="Paused" value={paused} />
|
||||||
|
<StatCard label="Needs attention" value={issues} highlight={issues > 0} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-[var(--ui-text-muted)]">
|
||||||
|
{total} {total === 1 ? "automation" : "automations"}
|
||||||
|
</span>
|
||||||
|
<Link to="/agents/automations/new" className={buttonVariants()}>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
New Automation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{schedulesQuery.isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-16 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-16 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
) : total === 0 ? (
|
||||||
|
<EmptyState />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{schedules.map((schedule) => (
|
||||||
|
<AutomationRow key={schedule.id} schedule={schedule} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
highlight,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
highlight?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] px-4 py-3">
|
||||||
|
<div className="text-xs text-[var(--ui-text-dim)]">{label}</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-2xl font-semibold",
|
||||||
|
highlight ? "text-[var(--ui-danger)]" : "text-[var(--ui-text)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[var(--ui-border)] bg-[var(--ui-surface)] px-6 py-14 text-center">
|
||||||
|
<div className="rounded-full bg-[var(--ui-panel-2)] p-3 text-[var(--ui-text-muted)]">
|
||||||
|
<LightningIcon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-sm font-medium text-[var(--ui-text)]">
|
||||||
|
No automations yet
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 max-w-sm text-sm text-[var(--ui-text-muted)]">
|
||||||
|
Schedule Open SWE to run on a recurring cadence — review code, triage
|
||||||
|
issues, or keep docs up to date.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/agents/automations/new"
|
||||||
|
className={cn(buttonVariants(), "mt-4")}
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
New Automation
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AutomationRow({ schedule }: { schedule: AgentSchedule }) {
|
||||||
|
const updateSchedule = useUpdateAgentSchedule()
|
||||||
|
const isToggling =
|
||||||
|
updateSchedule.isPending &&
|
||||||
|
updateSchedule.variables.scheduleId === schedule.id
|
||||||
|
|
||||||
|
const onToggle = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
updateSchedule.mutate({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
body: { enabled: !schedule.enabled },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/agents/automations/$scheduleId"
|
||||||
|
params={{ scheduleId: schedule.id }}
|
||||||
|
className="flex items-center gap-3 rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] px-4 py-3 transition-colors hover:border-[var(--ui-text-dim)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"size-2 shrink-0 rounded-full",
|
||||||
|
schedule.enabled ? "bg-[var(--ui-success)]" : "bg-[var(--ui-border)]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium text-[var(--ui-text)]">
|
||||||
|
{schedule.name}
|
||||||
|
</span>
|
||||||
|
{schedule.lastError && (
|
||||||
|
<WarningCircleIcon
|
||||||
|
className="size-3.5 shrink-0 text-[var(--ui-danger)]"
|
||||||
|
aria-label="Last run failed"
|
||||||
|
>
|
||||||
|
<title>Last run failed</title>
|
||||||
|
</WarningCircleIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-[var(--ui-text-dim)]">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ClockIcon className="size-3.5" />
|
||||||
|
{describeCron(schedule.schedule)}
|
||||||
|
</span>
|
||||||
|
{schedule.repo && <span>{schedule.repo}</span>}
|
||||||
|
<span>Last run: {formatDate(schedule.lastTriggeredAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
disabled={isToggling}
|
||||||
|
aria-label={schedule.enabled ? "Pause automation" : "Resume automation"}
|
||||||
|
className="shrink-0 rounded-md p-1.5 text-[var(--ui-text-dim)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{schedule.enabled ? (
|
||||||
|
<PauseIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<PlayIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
ui/src/components/agents/ScheduleTriggerPicker.tsx
Normal file
78
ui/src/components/agents/ScheduleTriggerPicker.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { CaretRightIcon, ClockIcon, PlusIcon } from "@phosphor-icons/react"
|
||||||
|
|
||||||
|
import { CRON_PRESETS } from "@/lib/agents/cron"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ScheduleTriggerPickerProps {
|
||||||
|
/** Called with a cron value for a preset, or null when the user picks Custom. */
|
||||||
|
onSelect: (cron: string | null) => void
|
||||||
|
triggerLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPTIONS: Array<{ id: string; label: string; cron: string | null }> = [
|
||||||
|
...CRON_PRESETS.map((preset) => ({
|
||||||
|
id: preset.id,
|
||||||
|
label: preset.label,
|
||||||
|
cron: preset.value,
|
||||||
|
})),
|
||||||
|
{ id: "custom", label: "Custom (cron)", cron: null },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ScheduleTriggerPicker({
|
||||||
|
onSelect,
|
||||||
|
triggerLabel = "Add Trigger",
|
||||||
|
}: ScheduleTriggerPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((value) => !value)}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2.5 text-sm text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
|
||||||
|
>
|
||||||
|
<PlusIcon className="size-4" />
|
||||||
|
{triggerLabel}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full left-0 z-50 mt-1 w-72 overflow-hidden rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] py-1 shadow-lg">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 text-[11px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase">
|
||||||
|
<ClockIcon className="size-3.5" />
|
||||||
|
Scheduled
|
||||||
|
</div>
|
||||||
|
{OPTIONS.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(option.cron)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--ui-text-muted)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1">{option.label}</span>
|
||||||
|
{option.cron === null && (
|
||||||
|
<CaretRightIcon className="size-3.5 opacity-50" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,48 @@
|
||||||
import type { AgentThread, Message } from "./types";
|
import type { AgentSchedule, AgentThread, Message } from "./types"
|
||||||
|
|
||||||
export type { AgentThread, Message };
|
export type { AgentSchedule, AgentThread, Message }
|
||||||
|
|
||||||
export interface ThreadCreateRequest {
|
export interface ThreadCreateRequest {
|
||||||
prompt: string;
|
prompt: string
|
||||||
repo?: string | null;
|
repo?: string | null
|
||||||
model_id?: string | null;
|
model_id?: string | null
|
||||||
effort?: string | null;
|
effort?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThreadMessageRequest {
|
export interface ThreadMessageRequest {
|
||||||
content: string;
|
content: string
|
||||||
model_id?: string | null;
|
model_id?: string | null
|
||||||
effort?: string | null;
|
effort?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(/\/$/, "");
|
export interface ScheduleCreateRequest {
|
||||||
|
prompt: string
|
||||||
|
schedule: string
|
||||||
|
name?: string | null
|
||||||
|
repo?: string | null
|
||||||
|
model_id?: string | null
|
||||||
|
effort?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
async function agentsRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
export interface ScheduleUpdateRequest {
|
||||||
|
prompt?: string | null
|
||||||
|
schedule?: string | null
|
||||||
|
name?: string | null
|
||||||
|
repo?: string | null
|
||||||
|
model_id?: string | null
|
||||||
|
effort?: string | null
|
||||||
|
enabled?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
|
||||||
|
/\/$/,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
async function agentsRequest<T>(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}/dashboard/api${path}`, {
|
const res = await fetch(`${API_BASE}/dashboard/api${path}`, {
|
||||||
...init,
|
...init,
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
|
|
@ -25,25 +50,46 @@ async function agentsRequest<T>(path: string, init: RequestInit = {}): Promise<T
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = res.statusText;
|
let message = res.statusText
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json()
|
||||||
if (body?.detail) {
|
if (body?.detail) {
|
||||||
message = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
|
message =
|
||||||
|
typeof body.detail === "string"
|
||||||
|
? body.detail
|
||||||
|
: JSON.stringify(body.detail)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
throw new Error(message);
|
throw new Error(message)
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export const agentsApi = {
|
export const agentsApi = {
|
||||||
listThreads: () => agentsRequest<AgentThread[]>("/threads"),
|
listThreads: () => agentsRequest<Array<AgentThread>>("/threads"),
|
||||||
|
listSchedules: () => agentsRequest<Array<AgentSchedule>>("/schedules"),
|
||||||
|
createSchedule: (body: ScheduleCreateRequest) =>
|
||||||
|
agentsRequest<AgentSchedule>("/schedules", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
updateSchedule: (scheduleId: string, body: ScheduleUpdateRequest) =>
|
||||||
|
agentsRequest<AgentSchedule>(
|
||||||
|
`/schedules/${encodeURIComponent(scheduleId)}`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
deleteSchedule: (scheduleId: string) =>
|
||||||
|
agentsRequest<void>(`/schedules/${encodeURIComponent(scheduleId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
getThread: (threadId: string) =>
|
getThread: (threadId: string) =>
|
||||||
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}`),
|
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}`),
|
||||||
createThread: (body: ThreadCreateRequest) =>
|
createThread: (body: ThreadCreateRequest) =>
|
||||||
|
|
@ -52,56 +98,64 @@ export const agentsApi = {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
sendMessage: (threadId: string, body: ThreadMessageRequest) =>
|
sendMessage: (threadId: string, body: ThreadMessageRequest) =>
|
||||||
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/messages`, {
|
agentsRequest<AgentThread>(
|
||||||
method: "POST",
|
`/threads/${encodeURIComponent(threadId)}/messages`,
|
||||||
body: JSON.stringify(body),
|
{
|
||||||
}),
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
),
|
||||||
cancelThread: (threadId: string) =>
|
cancelThread: (threadId: string) =>
|
||||||
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/cancel`, {
|
agentsRequest<AgentThread>(
|
||||||
method: "POST",
|
`/threads/${encodeURIComponent(threadId)}/cancel`,
|
||||||
}),
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
),
|
||||||
deleteThread: (threadId: string) =>
|
deleteThread: (threadId: string) =>
|
||||||
agentsRequest<void>(`/threads/${encodeURIComponent(threadId)}`, {
|
agentsRequest<void>(`/threads/${encodeURIComponent(threadId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
streamUrl: (threadId: string) =>
|
streamUrl: (threadId: string) =>
|
||||||
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
`${API_BASE}/dashboard/api/threads/${encodeURIComponent(threadId)}/stream`,
|
||||||
};
|
|
||||||
|
|
||||||
export function formatRelativeTime(ts: number): string {
|
|
||||||
const diff = Date.now() - ts;
|
|
||||||
const mins = Math.floor(diff / 60000);
|
|
||||||
if (mins < 60) return `${mins}m`;
|
|
||||||
const hours = Math.floor(mins / 60);
|
|
||||||
if (hours < 24) return `${hours}h`;
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
if (days < 7) return `${days}d`;
|
|
||||||
const weeks = Math.floor(days / 7);
|
|
||||||
return `${weeks}w`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ThreadGroup = "today" | "last30" | "older";
|
export function formatRelativeTime(ts: number): string {
|
||||||
|
const diff = Date.now() - ts
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 60) return `${mins}m`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `${hours}h`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days < 7) return `${days}d`
|
||||||
|
const weeks = Math.floor(days / 7)
|
||||||
|
return `${weeks}w`
|
||||||
|
}
|
||||||
|
|
||||||
export function groupThreads(threads: AgentThread[]): Record<ThreadGroup, AgentThread[]> {
|
export type ThreadGroup = "today" | "last30" | "older"
|
||||||
const todayStart = new Date();
|
|
||||||
todayStart.setHours(0, 0, 0, 0);
|
|
||||||
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const groups: Record<ThreadGroup, AgentThread[]> = {
|
export function groupThreads(
|
||||||
|
threads: Array<AgentThread>
|
||||||
|
): Record<ThreadGroup, Array<AgentThread>> {
|
||||||
|
const todayStart = new Date()
|
||||||
|
todayStart.setHours(0, 0, 0, 0)
|
||||||
|
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const groups: Record<ThreadGroup, Array<AgentThread>> = {
|
||||||
today: [],
|
today: [],
|
||||||
last30: [],
|
last30: [],
|
||||||
older: [],
|
older: [],
|
||||||
};
|
}
|
||||||
|
|
||||||
for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) {
|
for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) {
|
||||||
if (thread.updatedAt >= todayStart.getTime()) {
|
if (thread.updatedAt >= todayStart.getTime()) {
|
||||||
groups.today.push(thread);
|
groups.today.push(thread)
|
||||||
} else if (thread.updatedAt >= thirtyDaysAgo) {
|
} else if (thread.updatedAt >= thirtyDaysAgo) {
|
||||||
groups.last30.push(thread);
|
groups.last30.push(thread)
|
||||||
} else {
|
} else {
|
||||||
groups.older.push(thread);
|
groups.older.push(thread)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
ui/src/lib/agents/cron.ts
Normal file
73
ui/src/lib/agents/cron.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
export interface CronPreset {
|
||||||
|
id: "hourly" | "daily" | "weekly"
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CRON_PRESETS: Array<CronPreset> = [
|
||||||
|
{ id: "hourly", label: "Hourly", value: "0 * * * *" },
|
||||||
|
{ id: "daily", label: "Daily", value: "0 9 * * *" },
|
||||||
|
{ id: "weekly", label: "Weekly", value: "0 9 * * 1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DAY_NAMES = [
|
||||||
|
"Sunday",
|
||||||
|
"Monday",
|
||||||
|
"Tuesday",
|
||||||
|
"Wednesday",
|
||||||
|
"Thursday",
|
||||||
|
"Friday",
|
||||||
|
"Saturday",
|
||||||
|
]
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return n.toString().padStart(2, "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(minute: number, hour: number): string {
|
||||||
|
return `${pad(hour)}:${pad(minute)} UTC`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort human description of a 5-field cron expression. */
|
||||||
|
export function describeCron(expr: string): string {
|
||||||
|
const parts = expr.trim().split(/\s+/)
|
||||||
|
if (parts.length !== 5) return expr
|
||||||
|
const [min, hour, dom, mon, dow] = parts
|
||||||
|
if (!min || !hour || !dom || !mon || !dow) return expr
|
||||||
|
|
||||||
|
if (min === "0" && hour === "*" && dom === "*" && mon === "*" && dow === "*") {
|
||||||
|
return "Every hour"
|
||||||
|
}
|
||||||
|
|
||||||
|
const everyNHours = hour.match(/^\*\/(\d+)$/)
|
||||||
|
if (everyNHours && min === "0" && dom === "*" && mon === "*" && dow === "*") {
|
||||||
|
return `Every ${everyNHours[1]} hours`
|
||||||
|
}
|
||||||
|
|
||||||
|
const minNum = Number(min)
|
||||||
|
const hourNum = Number(hour)
|
||||||
|
const timeKnown =
|
||||||
|
Number.isInteger(minNum) &&
|
||||||
|
Number.isInteger(hourNum) &&
|
||||||
|
minNum >= 0 &&
|
||||||
|
hourNum >= 0
|
||||||
|
|
||||||
|
if (timeKnown && dom === "*" && mon === "*") {
|
||||||
|
if (dow === "*") return `Daily at ${formatTime(minNum, hourNum)}`
|
||||||
|
if (dow === "1-5") return `Weekdays at ${formatTime(minNum, hourNum)}`
|
||||||
|
const dowNum = Number(dow)
|
||||||
|
if (Number.isInteger(dowNum) && dowNum >= 0 && dowNum <= 7) {
|
||||||
|
const name = DAY_NAMES[dowNum % 7]
|
||||||
|
return `Weekly on ${name} at ${formatTime(minNum, hourNum)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return expr
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match a cron expression back to a known preset id, if any. */
|
||||||
|
export function presetForCron(expr: string): CronPreset["id"] | "custom" {
|
||||||
|
const normalized = expr.trim().split(/\s+/).join(" ")
|
||||||
|
const match = CRON_PRESETS.find((p) => p.value === normalized)
|
||||||
|
return match?.id ?? "custom"
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
|
||||||
import { agentsApi } from "./api";
|
import { agentsApi } from "./api"
|
||||||
import { addPendingPrompt } from "./pendingPrompts";
|
import { addPendingPrompt } from "./pendingPrompts"
|
||||||
|
import type { ScheduleUpdateRequest } from "./api"
|
||||||
|
|
||||||
export const agentThreadKeys = {
|
export const agentThreadKeys = {
|
||||||
all: ["agent-threads"] as const,
|
all: ["agent-threads"] as const,
|
||||||
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
||||||
};
|
}
|
||||||
|
|
||||||
|
export const agentScheduleKeys = {
|
||||||
|
all: ["agent-schedules"] as const,
|
||||||
|
}
|
||||||
|
|
||||||
export function useAgentThreads() {
|
export function useAgentThreads() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: agentThreadKeys.all,
|
queryKey: agentThreadKeys.all,
|
||||||
queryFn: () => agentsApi.listThreads(),
|
queryFn: () => agentsApi.listThreads(),
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentThread(threadId: string) {
|
export function useAgentThread(threadId: string) {
|
||||||
|
|
@ -21,38 +26,79 @@ export function useAgentThread(threadId: string) {
|
||||||
queryKey: agentThreadKeys.detail(threadId),
|
queryKey: agentThreadKeys.detail(threadId),
|
||||||
queryFn: () => agentsApi.getThread(threadId),
|
queryFn: () => agentsApi.getThread(threadId),
|
||||||
refetchInterval: (query) => {
|
refetchInterval: (query) => {
|
||||||
const status = query.state.data?.status;
|
const status = query.state.data?.status
|
||||||
return status === "running" ? 2000 : false;
|
return status === "running" ? 2000 : false
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAgentSchedules() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: agentScheduleKeys.all,
|
||||||
|
queryFn: () => agentsApi.listSchedules(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAgentSchedule() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: agentsApi.createSchedule,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAgentSchedule() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (vars: { scheduleId: string; body: ScheduleUpdateRequest }) =>
|
||||||
|
agentsApi.updateSchedule(vars.scheduleId, vars.body),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteAgentSchedule() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: agentsApi.deleteSchedule,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentScheduleKeys.all })
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateAgentThread() {
|
export function useCreateAgentThread() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: agentsApi.createThread,
|
mutationFn: agentsApi.createThread,
|
||||||
onSuccess: (thread, variables) => {
|
onSuccess: (thread, variables) => {
|
||||||
addPendingPrompt(thread.id, variables.prompt, thread.messages.length);
|
addPendingPrompt(thread.id, variables.prompt, thread.messages.length)
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(thread.id), {
|
queryClient.setQueryData(agentThreadKeys.detail(thread.id), {
|
||||||
...thread,
|
...thread,
|
||||||
status: thread.status === "idle" ? "running" : thread.status,
|
status: thread.status === "idle" ? "running" : thread.status,
|
||||||
});
|
})
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||||
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } });
|
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendAgentMessageVariables {
|
export interface SendAgentMessageVariables {
|
||||||
content: string;
|
content: string
|
||||||
model_id?: string | null;
|
model_id?: string | null
|
||||||
effort?: string | null;
|
effort?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSendAgentMessage(threadId: string) {
|
export function useSendAgentMessage(threadId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (vars: SendAgentMessageVariables) =>
|
mutationFn: (vars: SendAgentMessageVariables) =>
|
||||||
|
|
@ -62,50 +108,56 @@ export function useSendAgentMessage(threadId: string) {
|
||||||
effort: vars.effort,
|
effort: vars.effort,
|
||||||
}),
|
}),
|
||||||
onMutate: (vars) => {
|
onMutate: (vars) => {
|
||||||
const cached = queryClient.getQueryData<{ messages?: unknown[] }>(
|
const cached = queryClient.getQueryData<{ messages?: Array<unknown> }>(
|
||||||
agentThreadKeys.detail(threadId),
|
agentThreadKeys.detail(threadId)
|
||||||
);
|
)
|
||||||
const insertAt = Array.isArray(cached?.messages) ? cached.messages.length : 0;
|
const insertAt = Array.isArray(cached?.messages)
|
||||||
addPendingPrompt(threadId, vars.content, insertAt);
|
? cached.messages.length
|
||||||
|
: 0
|
||||||
|
addPendingPrompt(threadId, vars.content, insertAt)
|
||||||
},
|
},
|
||||||
onSuccess: (thread) => {
|
onSuccess: (thread) => {
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev: typeof thread | undefined) => {
|
queryClient.setQueryData(
|
||||||
if (!prev) return thread;
|
agentThreadKeys.detail(threadId),
|
||||||
return {
|
(prev: typeof thread | undefined) => {
|
||||||
...thread,
|
if (!prev) return thread
|
||||||
messages: thread.messages.length > 0 ? thread.messages : prev.messages,
|
return {
|
||||||
};
|
...thread,
|
||||||
});
|
messages:
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
thread.messages.length > 0 ? thread.messages : prev.messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCancelAgentThread(threadId: string) {
|
export function useCancelAgentThread(threadId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => agentsApi.cancelThread(threadId),
|
mutationFn: () => agentsApi.cancelThread(threadId),
|
||||||
onSuccess: (thread) => {
|
onSuccess: (thread) => {
|
||||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread);
|
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread)
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteAgentThread() {
|
export function useDeleteAgentThread() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient()
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
||||||
onSuccess: (_, threadId) => {
|
onSuccess: (_, threadId) => {
|
||||||
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) });
|
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
||||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname
|
||||||
if (path.includes(`/agents/${threadId}`)) {
|
if (path.includes(`/agents/${threadId}`)) {
|
||||||
navigate({ to: "/agents" });
|
navigate({ to: "/agents" })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export type Author = "user" | "agent" | "system" | "tool";
|
export type Author = "user" | "agent" | "system" | "tool"
|
||||||
|
|
||||||
export type ChunkKind =
|
export type ChunkKind =
|
||||||
| "text"
|
| "text"
|
||||||
|
|
@ -7,17 +7,27 @@ export type ChunkKind =
|
||||||
| "list"
|
| "list"
|
||||||
| "tool-execution"
|
| "tool-execution"
|
||||||
| "todo"
|
| "todo"
|
||||||
| "image";
|
| "image"
|
||||||
|
|
||||||
export type TodoStatus = "pending" | "in_progress" | "completed";
|
export type TodoStatus = "pending" | "in_progress" | "completed"
|
||||||
|
|
||||||
export type AgentStatus = "idle" | "running" | "finished" | "interrupted" | "error";
|
export type AgentStatus =
|
||||||
|
| "idle"
|
||||||
|
| "running"
|
||||||
|
| "finished"
|
||||||
|
| "interrupted"
|
||||||
|
| "error"
|
||||||
|
|
||||||
export type AgentSource = "dashboard" | "github" | "slack" | "linear";
|
export type AgentSource =
|
||||||
|
| "dashboard"
|
||||||
|
| "github"
|
||||||
|
| "slack"
|
||||||
|
| "linear"
|
||||||
|
| "schedule"
|
||||||
|
|
||||||
export interface TodoItem {
|
export interface TodoItem {
|
||||||
content: string;
|
content: string
|
||||||
status: TodoStatus;
|
status: TodoStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AcpToolKind =
|
export type AcpToolKind =
|
||||||
|
|
@ -31,71 +41,71 @@ export type AcpToolKind =
|
||||||
| "fetch"
|
| "fetch"
|
||||||
| "slack"
|
| "slack"
|
||||||
| "linear"
|
| "linear"
|
||||||
| "other";
|
| "other"
|
||||||
|
|
||||||
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";
|
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error"
|
||||||
|
|
||||||
export interface AcpToolLocation {
|
export interface AcpToolLocation {
|
||||||
path: string;
|
path: string
|
||||||
line?: number;
|
line?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DiffData {
|
export interface DiffData {
|
||||||
originalContent: string | null;
|
originalContent: string | null
|
||||||
newContent: string;
|
newContent: string
|
||||||
filePath: string;
|
filePath: string
|
||||||
isNewFile: boolean;
|
isNewFile: boolean
|
||||||
isBinary: boolean;
|
isBinary: boolean
|
||||||
isTruncated: boolean;
|
isTruncated: boolean
|
||||||
totalLines: number;
|
totalLines: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolExecutionChunk {
|
export interface ToolExecutionChunk {
|
||||||
kind: "tool-execution";
|
kind: "tool-execution"
|
||||||
toolCallId: string;
|
toolCallId: string
|
||||||
title: string;
|
title: string
|
||||||
toolKind: AcpToolKind;
|
toolKind: AcpToolKind
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>
|
||||||
status: AcpToolStatus;
|
status: AcpToolStatus
|
||||||
output?: string;
|
output?: string
|
||||||
elapsedMs?: number;
|
elapsedMs?: number
|
||||||
approvalRequestId?: string;
|
approvalRequestId?: string
|
||||||
diffData?: DiffData;
|
diffData?: DiffData
|
||||||
diffs?: DiffData[];
|
diffs?: Array<DiffData>
|
||||||
locations?: AcpToolLocation[];
|
locations?: Array<AcpToolLocation>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TextChunk {
|
export interface TextChunk {
|
||||||
kind: "text";
|
kind: "text"
|
||||||
text: string;
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodeChunk {
|
export interface CodeChunk {
|
||||||
kind: "code";
|
kind: "code"
|
||||||
text: string;
|
text: string
|
||||||
language?: string;
|
language?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ErrorChunk {
|
export interface ErrorChunk {
|
||||||
kind: "error";
|
kind: "error"
|
||||||
text: string;
|
text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListChunk {
|
export interface ListChunk {
|
||||||
kind: "list";
|
kind: "list"
|
||||||
lines: string[];
|
lines: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TodoChunk {
|
export interface TodoChunk {
|
||||||
kind: "todo";
|
kind: "todo"
|
||||||
todos: TodoItem[];
|
todos: Array<TodoItem>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageChunk {
|
export interface ImageChunk {
|
||||||
kind: "image";
|
kind: "image"
|
||||||
base64: string;
|
base64: string
|
||||||
mimeType: string;
|
mimeType: string
|
||||||
fileName?: string;
|
fileName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Chunk =
|
export type Chunk =
|
||||||
|
|
@ -105,57 +115,76 @@ export type Chunk =
|
||||||
| ListChunk
|
| ListChunk
|
||||||
| ToolExecutionChunk
|
| ToolExecutionChunk
|
||||||
| TodoChunk
|
| TodoChunk
|
||||||
| ImageChunk;
|
| ImageChunk
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string
|
||||||
author: Author;
|
author: Author
|
||||||
timestamp: string;
|
timestamp: string
|
||||||
chunks: Chunk[];
|
chunks: Array<Chunk>
|
||||||
hidden?: boolean;
|
hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Project {
|
export interface Project {
|
||||||
id: string;
|
id: string
|
||||||
path: string;
|
path: string
|
||||||
name: string;
|
name: string
|
||||||
createdAt: number;
|
createdAt: number
|
||||||
lastOpenedAt: number;
|
lastOpenedAt: number
|
||||||
gitBranch?: string;
|
gitBranch?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSchedule {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
prompt: string
|
||||||
|
schedule: string
|
||||||
|
repo: string | null
|
||||||
|
model: string
|
||||||
|
effort?: string | null
|
||||||
|
enabled: boolean
|
||||||
|
cronId?: string | null
|
||||||
|
lastThreadId?: string | null
|
||||||
|
lastRunId?: string | null
|
||||||
|
lastTriggeredAt?: string | null
|
||||||
|
lastError?: string | null
|
||||||
|
lastErrorAt?: string | null
|
||||||
|
createdAt?: string | null
|
||||||
|
updatedAt?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentThread {
|
export interface AgentThread {
|
||||||
id: string;
|
id: string
|
||||||
title: string;
|
title: string
|
||||||
repo: string;
|
repo: string
|
||||||
repoFullName: string;
|
repoFullName: string
|
||||||
branch: string;
|
branch: string
|
||||||
model: string;
|
model: string
|
||||||
effort?: string | null;
|
effort?: string | null
|
||||||
source?: AgentSource;
|
source?: AgentSource
|
||||||
status: AgentStatus;
|
status: AgentStatus
|
||||||
createdAt: number;
|
createdAt: number
|
||||||
updatedAt: number;
|
updatedAt: number
|
||||||
messages: Message[];
|
messages: Array<Message>
|
||||||
pr?: {
|
pr?: {
|
||||||
number: number;
|
number: number
|
||||||
title: string;
|
title: string
|
||||||
state: "draft" | "open" | "merged";
|
state: "draft" | "open" | "merged"
|
||||||
headRef: string;
|
headRef: string
|
||||||
baseRef: string;
|
baseRef: string
|
||||||
url: string;
|
url: string
|
||||||
};
|
}
|
||||||
diffStats?: {
|
diffStats?: {
|
||||||
files: number;
|
files: number
|
||||||
additions: number;
|
additions: number
|
||||||
deletions: number;
|
deletions: number
|
||||||
};
|
}
|
||||||
changedFiles?: Array<{
|
changedFiles?: Array<{
|
||||||
path: string;
|
path: string
|
||||||
additions: number;
|
additions: number
|
||||||
deletions: number;
|
deletions: number
|
||||||
patch?: string;
|
patch?: string
|
||||||
}>;
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GitFileStatus =
|
export type GitFileStatus =
|
||||||
|
|
@ -164,11 +193,11 @@ export type GitFileStatus =
|
||||||
| "index-deleted"
|
| "index-deleted"
|
||||||
| "modified"
|
| "modified"
|
||||||
| "deleted"
|
| "deleted"
|
||||||
| "untracked";
|
| "untracked"
|
||||||
|
|
||||||
export interface GitStatusEntry {
|
export interface GitStatusEntry {
|
||||||
path: string;
|
path: string
|
||||||
status: GitFileStatus;
|
status: GitFileStatus
|
||||||
staged: boolean;
|
staged: boolean
|
||||||
originalPath?: string;
|
originalPath?: string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
||||||
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
||||||
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
||||||
|
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
|
||||||
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
|
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
|
||||||
|
import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
|
||||||
|
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
|
||||||
|
|
||||||
const UsageRoute = UsageRouteImport.update({
|
const UsageRoute = UsageRouteImport.update({
|
||||||
id: '/usage',
|
id: '/usage',
|
||||||
|
|
@ -83,11 +86,27 @@ const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
||||||
path: '/$threadId',
|
path: '/$threadId',
|
||||||
getParentRoute: () => AgentsRoute,
|
getParentRoute: () => AgentsRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentsAutomationsIndexRoute = AgentsAutomationsIndexRouteImport.update({
|
||||||
|
id: '/automations/',
|
||||||
|
path: '/automations/',
|
||||||
|
getParentRoute: () => AgentsRoute,
|
||||||
|
} as any)
|
||||||
const ReviewRepositoriesOwnerRoute = ReviewRepositoriesOwnerRouteImport.update({
|
const ReviewRepositoriesOwnerRoute = ReviewRepositoriesOwnerRouteImport.update({
|
||||||
id: '/review_/repositories/$owner',
|
id: '/review_/repositories/$owner',
|
||||||
path: '/review/repositories/$owner',
|
path: '/review/repositories/$owner',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AgentsAutomationsNewRoute = AgentsAutomationsNewRouteImport.update({
|
||||||
|
id: '/automations/new',
|
||||||
|
path: '/automations/new',
|
||||||
|
getParentRoute: () => AgentsRoute,
|
||||||
|
} as any)
|
||||||
|
const AgentsAutomationsScheduleIdRoute =
|
||||||
|
AgentsAutomationsScheduleIdRouteImport.update({
|
||||||
|
id: '/automations/$scheduleId',
|
||||||
|
path: '/automations/$scheduleId',
|
||||||
|
getParentRoute: () => AgentsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -102,7 +121,10 @@ export interface FileRoutesByFullPath {
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
|
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||||
|
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||||
|
'/agents/automations/': typeof AgentsAutomationsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
|
|
@ -116,7 +138,10 @@ export interface FileRoutesByTo {
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/review/styles': typeof ReviewStylesRoute
|
'/review/styles': typeof ReviewStylesRoute
|
||||||
'/agents': typeof AgentsIndexRoute
|
'/agents': typeof AgentsIndexRoute
|
||||||
|
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||||
|
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||||
|
'/agents/automations': typeof AgentsAutomationsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
|
|
@ -132,7 +157,10 @@ export interface FileRoutesById {
|
||||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||||
'/review_/styles': typeof ReviewStylesRoute
|
'/review_/styles': typeof ReviewStylesRoute
|
||||||
'/agents/': typeof AgentsIndexRoute
|
'/agents/': typeof AgentsIndexRoute
|
||||||
|
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||||
|
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||||
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||||
|
'/agents/automations/': typeof AgentsAutomationsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
|
@ -149,7 +177,10 @@ export interface FileRouteTypes {
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
|
| '/agents/automations/$scheduleId'
|
||||||
|
| '/agents/automations/new'
|
||||||
| '/review/repositories/$owner'
|
| '/review/repositories/$owner'
|
||||||
|
| '/agents/automations/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -163,7 +194,10 @@ export interface FileRouteTypes {
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/review/styles'
|
| '/review/styles'
|
||||||
| '/agents'
|
| '/agents'
|
||||||
|
| '/agents/automations/$scheduleId'
|
||||||
|
| '/agents/automations/new'
|
||||||
| '/review/repositories/$owner'
|
| '/review/repositories/$owner'
|
||||||
|
| '/agents/automations'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/'
|
| '/'
|
||||||
|
|
@ -178,7 +212,10 @@ export interface FileRouteTypes {
|
||||||
| '/agents/$threadId'
|
| '/agents/$threadId'
|
||||||
| '/review_/styles'
|
| '/review_/styles'
|
||||||
| '/agents/'
|
| '/agents/'
|
||||||
|
| '/agents/automations/$scheduleId'
|
||||||
|
| '/agents/automations/new'
|
||||||
| '/review_/repositories/$owner'
|
| '/review_/repositories/$owner'
|
||||||
|
| '/agents/automations/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
|
|
@ -197,13 +234,6 @@ export interface RootRouteChildren {
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/review': {
|
|
||||||
id: '/review'
|
|
||||||
path: '/review'
|
|
||||||
fullPath: '/review'
|
|
||||||
preLoaderRoute: typeof ReviewRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/usage': {
|
'/usage': {
|
||||||
id: '/usage'
|
id: '/usage'
|
||||||
path: '/usage'
|
path: '/usage'
|
||||||
|
|
@ -211,6 +241,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof UsageRouteImport
|
preLoaderRoute: typeof UsageRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/review': {
|
||||||
|
id: '/review'
|
||||||
|
path: '/review'
|
||||||
|
fullPath: '/review'
|
||||||
|
preLoaderRoute: typeof ReviewRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/my-settings': {
|
'/my-settings': {
|
||||||
id: '/my-settings'
|
id: '/my-settings'
|
||||||
path: '/my-settings'
|
path: '/my-settings'
|
||||||
|
|
@ -281,6 +318,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof AgentsThreadIdRouteImport
|
preLoaderRoute: typeof AgentsThreadIdRouteImport
|
||||||
parentRoute: typeof AgentsRoute
|
parentRoute: typeof AgentsRoute
|
||||||
}
|
}
|
||||||
|
'/agents/automations/': {
|
||||||
|
id: '/agents/automations/'
|
||||||
|
path: '/automations'
|
||||||
|
fullPath: '/agents/automations/'
|
||||||
|
preLoaderRoute: typeof AgentsAutomationsIndexRouteImport
|
||||||
|
parentRoute: typeof AgentsRoute
|
||||||
|
}
|
||||||
'/review_/repositories/$owner': {
|
'/review_/repositories/$owner': {
|
||||||
id: '/review_/repositories/$owner'
|
id: '/review_/repositories/$owner'
|
||||||
path: '/review/repositories/$owner'
|
path: '/review/repositories/$owner'
|
||||||
|
|
@ -288,17 +332,37 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof ReviewRepositoriesOwnerRouteImport
|
preLoaderRoute: typeof ReviewRepositoriesOwnerRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/agents/automations/new': {
|
||||||
|
id: '/agents/automations/new'
|
||||||
|
path: '/automations/new'
|
||||||
|
fullPath: '/agents/automations/new'
|
||||||
|
preLoaderRoute: typeof AgentsAutomationsNewRouteImport
|
||||||
|
parentRoute: typeof AgentsRoute
|
||||||
|
}
|
||||||
|
'/agents/automations/$scheduleId': {
|
||||||
|
id: '/agents/automations/$scheduleId'
|
||||||
|
path: '/automations/$scheduleId'
|
||||||
|
fullPath: '/agents/automations/$scheduleId'
|
||||||
|
preLoaderRoute: typeof AgentsAutomationsScheduleIdRouteImport
|
||||||
|
parentRoute: typeof AgentsRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentsRouteChildren {
|
interface AgentsRouteChildren {
|
||||||
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
|
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
|
||||||
AgentsIndexRoute: typeof AgentsIndexRoute
|
AgentsIndexRoute: typeof AgentsIndexRoute
|
||||||
|
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
|
||||||
|
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
|
||||||
|
AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentsRouteChildren: AgentsRouteChildren = {
|
const AgentsRouteChildren: AgentsRouteChildren = {
|
||||||
AgentsThreadIdRoute: AgentsThreadIdRoute,
|
AgentsThreadIdRoute: AgentsThreadIdRoute,
|
||||||
AgentsIndexRoute: AgentsIndexRoute,
|
AgentsIndexRoute: AgentsIndexRoute,
|
||||||
|
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
|
||||||
|
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
|
||||||
|
AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AgentsRouteWithChildren =
|
const AgentsRouteWithChildren =
|
||||||
|
|
|
||||||
46
ui/src/routes/agents/automations/$scheduleId.tsx
Normal file
46
ui/src/routes/agents/automations/$scheduleId.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { AutomationEditor } from "@/components/agents/AutomationEditor";
|
||||||
|
import { AgentsShell } from "@/components/agents/AgentsSidebar";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useAgentSchedules } from "@/lib/agents/queries";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/agents/automations/$scheduleId")({
|
||||||
|
component: EditAutomationPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function EditAutomationPage() {
|
||||||
|
const session = useSession();
|
||||||
|
const { scheduleId } = Route.useParams();
|
||||||
|
const schedulesQuery = useAgentSchedules();
|
||||||
|
|
||||||
|
if (!session.data) return null;
|
||||||
|
|
||||||
|
const schedule = schedulesQuery.data?.find((s) => s.id === scheduleId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentsShell user={session.data}>
|
||||||
|
{schedulesQuery.isLoading ? (
|
||||||
|
<div className="mx-auto w-full max-w-3xl px-6 py-10">
|
||||||
|
<Skeleton className="h-9 w-64" />
|
||||||
|
<Skeleton className="mt-6 h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
) : schedule ? (
|
||||||
|
<AutomationEditor mode="edit" schedule={schedule} />
|
||||||
|
) : (
|
||||||
|
<div className="mx-auto w-full max-w-3xl px-6 py-16 text-center">
|
||||||
|
<p className="text-sm text-[var(--ui-text-muted)]">
|
||||||
|
This automation could not be found.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/agents/automations"
|
||||||
|
className="mt-3 inline-block text-sm text-[var(--ui-accent)] hover:underline"
|
||||||
|
>
|
||||||
|
Back to Automations
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AgentsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
ui/src/routes/agents/automations/index.tsx
Normal file
20
ui/src/routes/agents/automations/index.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { AutomationsList } from "@/components/agents/AutomationsList";
|
||||||
|
import { AgentsShell } from "@/components/agents/AgentsSidebar";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/agents/automations/")({
|
||||||
|
component: AutomationsIndexPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function AutomationsIndexPage() {
|
||||||
|
const session = useSession();
|
||||||
|
if (!session.data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentsShell user={session.data}>
|
||||||
|
<AutomationsList />
|
||||||
|
</AgentsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
ui/src/routes/agents/automations/new.tsx
Normal file
20
ui/src/routes/agents/automations/new.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { AutomationEditor } from "@/components/agents/AutomationEditor";
|
||||||
|
import { AgentsShell } from "@/components/agents/AgentsSidebar";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/agents/automations/new")({
|
||||||
|
component: NewAutomationPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function NewAutomationPage() {
|
||||||
|
const session = useSession();
|
||||||
|
if (!session.data) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AgentsShell user={session.data}>
|
||||||
|
<AutomationEditor mode="create" />
|
||||||
|
</AgentsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue