mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +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_profile,
|
||||
)
|
||||
from .repo_access import require_repo_access_for_user
|
||||
from .review_style_jobs import (
|
||||
cancel_review_style_analysis,
|
||||
start_bootstrap_analysis,
|
||||
|
|
@ -58,6 +59,14 @@ from .review_styles import (
|
|||
normalize_repo_full_name,
|
||||
set_custom_prompt,
|
||||
)
|
||||
from .schedules import (
|
||||
ScheduleCreateBody,
|
||||
ScheduleUpdateBody,
|
||||
create_agent_schedule,
|
||||
delete_agent_schedule,
|
||||
list_agent_schedules,
|
||||
update_agent_schedule,
|
||||
)
|
||||
from .slack_oauth import (
|
||||
SLACK_STATE_COOKIE_NAME,
|
||||
build_authorize_url,
|
||||
|
|
@ -592,59 +601,6 @@ async def list_repos(
|
|||
}
|
||||
|
||||
|
||||
def _raise_for_github_repo_status(status_code: int) -> None:
|
||||
if status_code == 401:
|
||||
raise HTTPException(401, "github token expired, re-login required")
|
||||
if status_code == 404:
|
||||
raise HTTPException(404, "repository not found")
|
||||
if status_code == 403:
|
||||
raise HTTPException(403, "no access to this private repository")
|
||||
if status_code != 200:
|
||||
raise HTTPException(502, f"github API error ({status_code})")
|
||||
|
||||
|
||||
async def _assert_repo_available_for_style_analysis(full_name: str, token: str) -> None:
|
||||
"""Ensure the repo exists and is readable for style learning.
|
||||
|
||||
Public repositories are allowed without the GitHub App installed on them.
|
||||
Private repositories require the authenticated user to have read access.
|
||||
"""
|
||||
full_name = normalize_repo_full_name(full_name)
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
owner, name = full_name.split("/", 1)
|
||||
async with httpx.AsyncClient() as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{name}",
|
||||
headers=headers,
|
||||
)
|
||||
_raise_for_github_repo_status(r.status_code)
|
||||
body = r.json()
|
||||
if body.get("private") is not True:
|
||||
return
|
||||
# Private repo: 200 from GitHub implies the user's token can read it.
|
||||
|
||||
|
||||
async def _require_repo_access_for_user(login: str, full_name: str) -> str:
|
||||
"""Verify the user can read ``full_name`` on GitHub; return a valid access token."""
|
||||
token = await get_valid_access_token(login)
|
||||
if not token:
|
||||
raise HTTPException(401, "github token unavailable, re-login required")
|
||||
try:
|
||||
await _assert_repo_available_for_style_analysis(full_name, token)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 401:
|
||||
raise
|
||||
token = await get_valid_access_token(login, force_refresh=True)
|
||||
if not token:
|
||||
raise HTTPException(401, "github token expired, re-login required") from exc
|
||||
await _assert_repo_available_for_style_analysis(full_name, token)
|
||||
return token
|
||||
|
||||
|
||||
@router.get("/review-styles")
|
||||
async def api_list_review_styles(
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
|
|
@ -665,7 +621,7 @@ async def api_create_review_style(
|
|||
body: ReviewStyleCreate,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
await _require_repo_access_for_user(session["sub"], body.full_name)
|
||||
await require_repo_access_for_user(session["sub"], body.full_name)
|
||||
return await create_review_style(body.full_name, session["sub"])
|
||||
|
||||
|
||||
|
|
@ -693,7 +649,7 @@ async def api_update_review_style_prompt(
|
|||
record = await get_review_style(full_name)
|
||||
if not record:
|
||||
raise HTTPException(404, "review style not found")
|
||||
await _require_repo_access_for_user(session["sub"], full_name)
|
||||
await require_repo_access_for_user(session["sub"], full_name)
|
||||
return await set_custom_prompt(full_name, body.custom_prompt)
|
||||
|
||||
|
||||
|
|
@ -703,7 +659,7 @@ async def api_analyze_review_style(
|
|||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
full_name = normalize_repo_full_name(full_name)
|
||||
token = await _require_repo_access_for_user(session["sub"], full_name)
|
||||
token = await require_repo_access_for_user(session["sub"], full_name)
|
||||
record = await get_review_style(full_name)
|
||||
if not record:
|
||||
record = await create_review_style(full_name, session["sub"])
|
||||
|
|
@ -762,6 +718,41 @@ async def api_agent_usage_leaderboard(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/schedules")
|
||||
async def api_list_schedules(
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> list[dict[str, Any]]:
|
||||
return await list_agent_schedules(session["sub"], email=session.get("email"))
|
||||
|
||||
|
||||
@router.post("/schedules")
|
||||
async def api_create_schedule(
|
||||
body: ScheduleCreateBody,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
return await create_agent_schedule(session["sub"], body, email=session.get("email"))
|
||||
|
||||
|
||||
@router.patch("/schedules/{schedule_id}")
|
||||
async def api_update_schedule(
|
||||
schedule_id: str,
|
||||
body: ScheduleUpdateBody,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> dict[str, Any]:
|
||||
return await update_agent_schedule(
|
||||
schedule_id, session["sub"], body, email=session.get("email")
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/schedules/{schedule_id}")
|
||||
async def api_delete_schedule(
|
||||
schedule_id: str,
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
) -> Response:
|
||||
await delete_agent_schedule(schedule_id, session["sub"], email=session.get("email"))
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.get("/threads")
|
||||
async def api_list_threads(
|
||||
session: dict[str, Any] = _SESSION_DEP,
|
||||
|
|
|
|||
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_STREAM_MODES: tuple[str, ...] = ("values", "updates", "messages-tuple")
|
||||
# Sources whose threads should surface in the Agents UI (besides "dashboard").
|
||||
_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear")
|
||||
_SURFACED_SOURCES: tuple[str, ...] = ("dashboard", "github", "slack", "linear", "schedule")
|
||||
|
||||
|
||||
def _agent_version_metadata() -> dict[str, str]:
|
||||
|
|
|
|||
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,
|
||||
# for sources that carry a mapped GitHub login (Slack, dashboard). This is
|
||||
# what lets the agent open PRs as the triggering user.
|
||||
if source in ("slack", "dashboard") and isinstance(github_login, str) and github_login.strip():
|
||||
if (
|
||||
source in ("slack", "dashboard", "schedule")
|
||||
and isinstance(github_login, str)
|
||||
and github_login.strip()
|
||||
):
|
||||
try:
|
||||
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
|
||||
except ValueError as exc:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
"graphs": {
|
||||
"agent": "agent.server:get_agent",
|
||||
"reviewer": "agent.reviewer:get_reviewer_agent",
|
||||
"analyzer": "agent.analyzer:get_analyzer"
|
||||
"analyzer": "agent.analyzer:get_analyzer",
|
||||
"scheduler": "agent.scheduler:get_scheduler"
|
||||
},
|
||||
"dependencies": ["."],
|
||||
"http": {
|
||||
|
|
|
|||
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 {
|
||||
CalendarBlankIcon,
|
||||
ChatCircleIcon,
|
||||
CheckCircleIcon,
|
||||
GitBranchIcon,
|
||||
GitPullRequestIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5";
|
||||
import { SiLinear } from "react-icons/si";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
} from "@phosphor-icons/react"
|
||||
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
|
||||
import { SiLinear } from "react-icons/si"
|
||||
import type { ComponentType, SVGProps } from "react"
|
||||
|
||||
import type { AgentSource, AgentThread } from "@/lib/agents/types";
|
||||
import { formatRelativeTime } from "@/lib/agents/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||
import { formatRelativeTime } from "@/lib/agents/api"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
||||
dashboard: { icon: ChatCircleIcon, label: "Dashboard" },
|
||||
github: { icon: IoLogoGithub, label: "GitHub" },
|
||||
slack: { icon: IoLogoSlack, label: "Slack" },
|
||||
linear: { icon: SiLinear, label: "Linear" },
|
||||
};
|
||||
schedule: { icon: CalendarBlankIcon, label: "Schedule" },
|
||||
}
|
||||
|
||||
interface AgentRunCardProps {
|
||||
thread: AgentThread;
|
||||
thread: AgentThread
|
||||
}
|
||||
|
||||
export function AgentRunCard({ thread }: AgentRunCardProps) {
|
||||
const stats = thread.diffStats;
|
||||
const hasPr = Boolean(thread.pr);
|
||||
const source = thread.source ? SOURCE_META[thread.source] : null;
|
||||
const SourceIcon = source?.icon;
|
||||
const stats = thread.diffStats
|
||||
const hasPr = Boolean(thread.pr)
|
||||
const source = thread.source ? SOURCE_META[thread.source] : null
|
||||
const SourceIcon = source?.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -45,8 +47,12 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
|||
{stats.files} {stats.files === 1 ? "file" : "files"}
|
||||
</div>
|
||||
<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-danger)]">-{stats.deletions}</span>
|
||||
<span className="text-[var(--ui-success)]">
|
||||
+{stats.additions}
|
||||
</span>
|
||||
<span className="text-[var(--ui-danger)]">
|
||||
-{stats.deletions}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -68,11 +74,16 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
|||
</div>
|
||||
|
||||
<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)]">
|
||||
{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} />
|
||||
{source.label}
|
||||
</span>
|
||||
|
|
@ -91,7 +102,9 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
|||
</>
|
||||
)}
|
||||
<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>
|
||||
|
||||
|
|
@ -100,10 +113,10 @@ export function AgentRunCard({ thread }: AgentRunCardProps) {
|
|||
"size-5 shrink-0",
|
||||
thread.status === "finished"
|
||||
? "text-[var(--ui-text-dim)] opacity-100"
|
||||
: "opacity-0",
|
||||
: "opacity-0"
|
||||
)}
|
||||
weight="regular"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,59 @@
|
|||
import { Link } from "@tanstack/react-router";
|
||||
import { Link } from "@tanstack/react-router"
|
||||
import {
|
||||
CalendarBlankIcon,
|
||||
ChartLineUpIcon,
|
||||
ChatCircleIcon,
|
||||
LightningIcon,
|
||||
PlusIcon,
|
||||
XIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5";
|
||||
import { SiLinear } from "react-icons/si";
|
||||
import type { ComponentType, SVGProps } from "react";
|
||||
} from "@phosphor-icons/react"
|
||||
import { IoLogoGithub, IoLogoSlack } from "react-icons/io5"
|
||||
import { SiLinear } from "react-icons/si"
|
||||
import type { ComponentType, SVGProps } from "react"
|
||||
|
||||
import type { SessionUser } from "@/lib/api";
|
||||
import type { AgentSource, AgentThread } from "@/lib/agents/types";
|
||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu";
|
||||
import type { SessionUser } from "@/lib/api"
|
||||
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||
import {
|
||||
SidebarCollapseButton,
|
||||
SidebarFrame,
|
||||
useSidebarLayout,
|
||||
} from "@/components/sidebar-layout";
|
||||
import { groupThreads } from "@/lib/agents/api";
|
||||
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries";
|
||||
import { cn } from "@/lib/utils";
|
||||
} from "@/components/sidebar-layout"
|
||||
import { groupThreads } from "@/lib/agents/api"
|
||||
import { useAgentThreads, useDeleteAgentThread } from "@/lib/agents/queries"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>;
|
||||
type SourceIcon = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
const SOURCE_META: Record<AgentSource, { icon: SourceIcon; label: string }> = {
|
||||
dashboard: { icon: ChatCircleIcon, label: "Started from the dashboard" },
|
||||
github: { icon: IoLogoGithub, label: "Triggered from GitHub" },
|
||||
slack: { icon: IoLogoSlack, label: "Triggered from Slack" },
|
||||
linear: { icon: SiLinear, label: "Triggered from Linear" },
|
||||
};
|
||||
|
||||
interface AgentsSidebarProps {
|
||||
user: SessionUser;
|
||||
activeThreadId?: string;
|
||||
schedule: { icon: CalendarBlankIcon, label: "Triggered from a schedule" },
|
||||
}
|
||||
|
||||
const NAV = [{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon }] as const;
|
||||
interface AgentsSidebarProps {
|
||||
user: SessionUser
|
||||
activeThreadId?: string
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ to: "/agents/automations", label: "Automations", icon: LightningIcon },
|
||||
{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon },
|
||||
] as const
|
||||
|
||||
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||
const threadsQuery = useAgentThreads();
|
||||
const threads = threadsQuery.data ?? [];
|
||||
const groups = groupThreads(threads);
|
||||
const layout = useSidebarLayout();
|
||||
const threadsQuery = useAgentThreads()
|
||||
const threads = threadsQuery.data ?? []
|
||||
const groups = groupThreads(threads)
|
||||
const layout = useSidebarLayout()
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Link
|
||||
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.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
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)]"
|
||||
activeProps={{
|
||||
className:
|
||||
"bg-[var(--ui-sidebar-hover)] !text-[var(--ui-text)] font-medium",
|
||||
}}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
|
|
@ -109,7 +122,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
|||
<SidebarUserMenu user={user} showSettingsLink />
|
||||
</div>
|
||||
</SidebarFrame>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadGroup({
|
||||
|
|
@ -118,12 +131,12 @@ function ThreadGroup({
|
|||
activeThreadId,
|
||||
onNavigate,
|
||||
}: {
|
||||
label: string;
|
||||
threads: Array<AgentThread>;
|
||||
activeThreadId?: string;
|
||||
onNavigate?: () => void;
|
||||
label: string
|
||||
threads: Array<AgentThread>
|
||||
activeThreadId?: string
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
if (threads.length === 0) return null;
|
||||
if (threads.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mb-3">
|
||||
|
|
@ -139,7 +152,7 @@ function ThreadGroup({
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function ThreadRow({
|
||||
|
|
@ -147,27 +160,32 @@ function ThreadRow({
|
|||
isActive,
|
||||
onNavigate,
|
||||
}: {
|
||||
thread: AgentThread;
|
||||
isActive: boolean;
|
||||
onNavigate?: () => void;
|
||||
thread: AgentThread
|
||||
isActive: boolean
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
const deleteThread = useDeleteAgentThread();
|
||||
const deleteThread = useDeleteAgentThread()
|
||||
const badge =
|
||||
thread.diffStats && thread.diffStats.additions > 0
|
||||
? `+${thread.diffStats.additions}`
|
||||
: null;
|
||||
const isDeleting = deleteThread.isPending && deleteThread.variables === thread.id;
|
||||
: null
|
||||
const isDeleting =
|
||||
deleteThread.isPending && deleteThread.variables === thread.id
|
||||
|
||||
const onDelete = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDeleting) return;
|
||||
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`)) return;
|
||||
deleteThread.mutate(thread.id);
|
||||
};
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (isDeleting) return
|
||||
if (!window.confirm(`Delete "${thread.title}"? This cannot be undone.`))
|
||||
return
|
||||
deleteThread.mutate(thread.id)
|
||||
}
|
||||
|
||||
const source = thread.source && thread.source !== "dashboard" ? SOURCE_META[thread.source] : null;
|
||||
const SourceIcon = source?.icon;
|
||||
const source =
|
||||
thread.source && thread.source !== "dashboard"
|
||||
? SOURCE_META[thread.source]
|
||||
: null
|
||||
const SourceIcon = source?.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
|
@ -179,7 +197,7 @@ function ThreadRow({
|
|||
isActive
|
||||
? "bg-[var(--ui-accent-bubble)] text-[var(--ui-text)]"
|
||||
: "text-[var(--ui-text-muted)] hover:bg-[var(--ui-sidebar-hover)]",
|
||||
isDeleting && "opacity-50",
|
||||
isDeleting && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
|
@ -189,7 +207,7 @@ function ThreadRow({
|
|||
? "animate-pulse bg-[var(--ui-accent)]"
|
||||
: thread.status === "finished"
|
||||
? "bg-[var(--ui-accent)]"
|
||||
: "bg-[var(--ui-border)]",
|
||||
: "bg-[var(--ui-border)]"
|
||||
)}
|
||||
/>
|
||||
{source && SourceIcon && (
|
||||
|
|
@ -211,12 +229,12 @@ function ThreadRow({
|
|||
aria-label="Delete thread"
|
||||
onClick={onDelete}
|
||||
disabled={isDeleting}
|
||||
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)] group-hover:flex"
|
||||
className="hidden size-4 shrink-0 items-center justify-center rounded text-[var(--ui-text-dim)] group-hover:flex hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
|
||||
>
|
||||
<XIcon className="size-3" weight="bold" />
|
||||
</button>
|
||||
</Link>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentsShell({
|
||||
|
|
@ -224,14 +242,14 @@ export function AgentsShell({
|
|||
activeThreadId,
|
||||
children,
|
||||
}: {
|
||||
user: SessionUser;
|
||||
activeThreadId?: string;
|
||||
children: React.ReactNode;
|
||||
user: SessionUser
|
||||
activeThreadId?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
|
||||
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
|
||||
<div className="flex min-w-0 flex-1">{children}</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 {
|
||||
prompt: string;
|
||||
repo?: string | null;
|
||||
model_id?: string | null;
|
||||
effort?: string | null;
|
||||
prompt: string
|
||||
repo?: string | null
|
||||
model_id?: string | null
|
||||
effort?: string | null
|
||||
}
|
||||
|
||||
export interface ThreadMessageRequest {
|
||||
content: string;
|
||||
model_id?: string | null;
|
||||
effort?: string | null;
|
||||
content: string
|
||||
model_id?: 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}`, {
|
||||
...init,
|
||||
credentials: "include",
|
||||
|
|
@ -25,25 +50,46 @@ async function agentsRequest<T>(path: string, init: RequestInit = {}): Promise<T
|
|||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let message = res.statusText
|
||||
try {
|
||||
const body = await res.json();
|
||||
const body = await res.json()
|
||||
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 {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(message);
|
||||
throw new Error(message)
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
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) =>
|
||||
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}`),
|
||||
createThread: (body: ThreadCreateRequest) =>
|
||||
|
|
@ -52,56 +98,64 @@ export const agentsApi = {
|
|||
body: JSON.stringify(body),
|
||||
}),
|
||||
sendMessage: (threadId: string, body: ThreadMessageRequest) =>
|
||||
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
agentsRequest<AgentThread>(
|
||||
`/threads/${encodeURIComponent(threadId)}/messages`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
cancelThread: (threadId: string) =>
|
||||
agentsRequest<AgentThread>(`/threads/${encodeURIComponent(threadId)}/cancel`, {
|
||||
method: "POST",
|
||||
}),
|
||||
agentsRequest<AgentThread>(
|
||||
`/threads/${encodeURIComponent(threadId)}/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
),
|
||||
deleteThread: (threadId: string) =>
|
||||
agentsRequest<void>(`/threads/${encodeURIComponent(threadId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
streamUrl: (threadId: string) =>
|
||||
`${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[]> {
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000;
|
||||
export type ThreadGroup = "today" | "last30" | "older"
|
||||
|
||||
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: [],
|
||||
last30: [],
|
||||
older: [],
|
||||
};
|
||||
}
|
||||
|
||||
for (const thread of [...threads].sort((a, b) => b.updatedAt - a.updatedAt)) {
|
||||
if (thread.updatedAt >= todayStart.getTime()) {
|
||||
groups.today.push(thread);
|
||||
groups.today.push(thread)
|
||||
} else if (thread.updatedAt >= thirtyDaysAgo) {
|
||||
groups.last30.push(thread);
|
||||
groups.last30.push(thread)
|
||||
} 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 { useNavigate } from "@tanstack/react-router";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
|
||||
import { agentsApi } from "./api";
|
||||
import { addPendingPrompt } from "./pendingPrompts";
|
||||
import { agentsApi } from "./api"
|
||||
import { addPendingPrompt } from "./pendingPrompts"
|
||||
import type { ScheduleUpdateRequest } from "./api"
|
||||
|
||||
export const agentThreadKeys = {
|
||||
all: ["agent-threads"] as const,
|
||||
detail: (threadId: string) => ["agent-threads", threadId] as const,
|
||||
};
|
||||
}
|
||||
|
||||
export const agentScheduleKeys = {
|
||||
all: ["agent-schedules"] as const,
|
||||
}
|
||||
|
||||
export function useAgentThreads() {
|
||||
return useQuery({
|
||||
queryKey: agentThreadKeys.all,
|
||||
queryFn: () => agentsApi.listThreads(),
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useAgentThread(threadId: string) {
|
||||
|
|
@ -21,38 +26,79 @@ export function useAgentThread(threadId: string) {
|
|||
queryKey: agentThreadKeys.detail(threadId),
|
||||
queryFn: () => agentsApi.getThread(threadId),
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status === "running" ? 2000 : false;
|
||||
const status = query.state.data?.status
|
||||
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() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: agentsApi.createThread,
|
||||
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), {
|
||||
...thread,
|
||||
status: thread.status === "idle" ? "running" : thread.status,
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
||||
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } });
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||
navigate({ to: "/agents/$threadId", params: { threadId: thread.id } })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export interface SendAgentMessageVariables {
|
||||
content: string;
|
||||
model_id?: string | null;
|
||||
effort?: string | null;
|
||||
content: string
|
||||
model_id?: string | null
|
||||
effort?: string | null
|
||||
}
|
||||
|
||||
export function useSendAgentMessage(threadId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (vars: SendAgentMessageVariables) =>
|
||||
|
|
@ -62,50 +108,56 @@ export function useSendAgentMessage(threadId: string) {
|
|||
effort: vars.effort,
|
||||
}),
|
||||
onMutate: (vars) => {
|
||||
const cached = queryClient.getQueryData<{ messages?: unknown[] }>(
|
||||
agentThreadKeys.detail(threadId),
|
||||
);
|
||||
const insertAt = Array.isArray(cached?.messages) ? cached.messages.length : 0;
|
||||
addPendingPrompt(threadId, vars.content, insertAt);
|
||||
const cached = queryClient.getQueryData<{ messages?: Array<unknown> }>(
|
||||
agentThreadKeys.detail(threadId)
|
||||
)
|
||||
const insertAt = Array.isArray(cached?.messages)
|
||||
? cached.messages.length
|
||||
: 0
|
||||
addPendingPrompt(threadId, vars.content, insertAt)
|
||||
},
|
||||
onSuccess: (thread) => {
|
||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), (prev: typeof thread | undefined) => {
|
||||
if (!prev) return thread;
|
||||
return {
|
||||
...thread,
|
||||
messages: thread.messages.length > 0 ? thread.messages : prev.messages,
|
||||
};
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
||||
queryClient.setQueryData(
|
||||
agentThreadKeys.detail(threadId),
|
||||
(prev: typeof thread | undefined) => {
|
||||
if (!prev) return thread
|
||||
return {
|
||||
...thread,
|
||||
messages:
|
||||
thread.messages.length > 0 ? thread.messages : prev.messages,
|
||||
}
|
||||
}
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelAgentThread(threadId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => agentsApi.cancelThread(threadId),
|
||||
onSuccess: (thread) => {
|
||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread);
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
||||
queryClient.setQueryData(agentThreadKeys.detail(threadId), thread)
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAgentThread() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (threadId: string) => agentsApi.deleteThread(threadId),
|
||||
onSuccess: (_, threadId) => {
|
||||
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) });
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all });
|
||||
const path = window.location.pathname;
|
||||
queryClient.removeQueries({ queryKey: agentThreadKeys.detail(threadId) })
|
||||
queryClient.invalidateQueries({ queryKey: agentThreadKeys.all })
|
||||
const path = window.location.pathname
|
||||
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 =
|
||||
| "text"
|
||||
|
|
@ -7,17 +7,27 @@ export type ChunkKind =
|
|||
| "list"
|
||||
| "tool-execution"
|
||||
| "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 {
|
||||
content: string;
|
||||
status: TodoStatus;
|
||||
content: string
|
||||
status: TodoStatus
|
||||
}
|
||||
|
||||
export type AcpToolKind =
|
||||
|
|
@ -31,71 +41,71 @@ export type AcpToolKind =
|
|||
| "fetch"
|
||||
| "slack"
|
||||
| "linear"
|
||||
| "other";
|
||||
| "other"
|
||||
|
||||
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error";
|
||||
export type AcpToolStatus = "pending" | "in_progress" | "completed" | "error"
|
||||
|
||||
export interface AcpToolLocation {
|
||||
path: string;
|
||||
line?: number;
|
||||
path: string
|
||||
line?: number
|
||||
}
|
||||
|
||||
export interface DiffData {
|
||||
originalContent: string | null;
|
||||
newContent: string;
|
||||
filePath: string;
|
||||
isNewFile: boolean;
|
||||
isBinary: boolean;
|
||||
isTruncated: boolean;
|
||||
totalLines: number;
|
||||
originalContent: string | null
|
||||
newContent: string
|
||||
filePath: string
|
||||
isNewFile: boolean
|
||||
isBinary: boolean
|
||||
isTruncated: boolean
|
||||
totalLines: number
|
||||
}
|
||||
|
||||
export interface ToolExecutionChunk {
|
||||
kind: "tool-execution";
|
||||
toolCallId: string;
|
||||
title: string;
|
||||
toolKind: AcpToolKind;
|
||||
input?: Record<string, unknown>;
|
||||
status: AcpToolStatus;
|
||||
output?: string;
|
||||
elapsedMs?: number;
|
||||
approvalRequestId?: string;
|
||||
diffData?: DiffData;
|
||||
diffs?: DiffData[];
|
||||
locations?: AcpToolLocation[];
|
||||
kind: "tool-execution"
|
||||
toolCallId: string
|
||||
title: string
|
||||
toolKind: AcpToolKind
|
||||
input?: Record<string, unknown>
|
||||
status: AcpToolStatus
|
||||
output?: string
|
||||
elapsedMs?: number
|
||||
approvalRequestId?: string
|
||||
diffData?: DiffData
|
||||
diffs?: Array<DiffData>
|
||||
locations?: Array<AcpToolLocation>
|
||||
}
|
||||
|
||||
export interface TextChunk {
|
||||
kind: "text";
|
||||
text: string;
|
||||
kind: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface CodeChunk {
|
||||
kind: "code";
|
||||
text: string;
|
||||
language?: string;
|
||||
kind: "code"
|
||||
text: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface ErrorChunk {
|
||||
kind: "error";
|
||||
text: string;
|
||||
kind: "error"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ListChunk {
|
||||
kind: "list";
|
||||
lines: string[];
|
||||
kind: "list"
|
||||
lines: Array<string>
|
||||
}
|
||||
|
||||
export interface TodoChunk {
|
||||
kind: "todo";
|
||||
todos: TodoItem[];
|
||||
kind: "todo"
|
||||
todos: Array<TodoItem>
|
||||
}
|
||||
|
||||
export interface ImageChunk {
|
||||
kind: "image";
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
kind: "image"
|
||||
base64: string
|
||||
mimeType: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export type Chunk =
|
||||
|
|
@ -105,57 +115,76 @@ export type Chunk =
|
|||
| ListChunk
|
||||
| ToolExecutionChunk
|
||||
| TodoChunk
|
||||
| ImageChunk;
|
||||
| ImageChunk
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
author: Author;
|
||||
timestamp: string;
|
||||
chunks: Chunk[];
|
||||
hidden?: boolean;
|
||||
id: string
|
||||
author: Author
|
||||
timestamp: string
|
||||
chunks: Array<Chunk>
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
path: string;
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lastOpenedAt: number;
|
||||
gitBranch?: string;
|
||||
id: string
|
||||
path: string
|
||||
name: string
|
||||
createdAt: number
|
||||
lastOpenedAt: number
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
repo: string;
|
||||
repoFullName: string;
|
||||
branch: string;
|
||||
model: string;
|
||||
effort?: string | null;
|
||||
source?: AgentSource;
|
||||
status: AgentStatus;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
messages: Message[];
|
||||
id: string
|
||||
title: string
|
||||
repo: string
|
||||
repoFullName: string
|
||||
branch: string
|
||||
model: string
|
||||
effort?: string | null
|
||||
source?: AgentSource
|
||||
status: AgentStatus
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
messages: Array<Message>
|
||||
pr?: {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "draft" | "open" | "merged";
|
||||
headRef: string;
|
||||
baseRef: string;
|
||||
url: string;
|
||||
};
|
||||
number: number
|
||||
title: string
|
||||
state: "draft" | "open" | "merged"
|
||||
headRef: string
|
||||
baseRef: string
|
||||
url: string
|
||||
}
|
||||
diffStats?: {
|
||||
files: number;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
};
|
||||
files: number
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
changedFiles?: Array<{
|
||||
path: string;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
patch?: string;
|
||||
}>;
|
||||
path: string
|
||||
additions: number
|
||||
deletions: number
|
||||
patch?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export type GitFileStatus =
|
||||
|
|
@ -164,11 +193,11 @@ export type GitFileStatus =
|
|||
| "index-deleted"
|
||||
| "modified"
|
||||
| "deleted"
|
||||
| "untracked";
|
||||
| "untracked"
|
||||
|
||||
export interface GitStatusEntry {
|
||||
path: string;
|
||||
status: GitFileStatus;
|
||||
staged: boolean;
|
||||
originalPath?: string;
|
||||
path: string
|
||||
status: GitFileStatus
|
||||
staged: boolean
|
||||
originalPath?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
||||
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
||||
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 AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
|
||||
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
|
||||
|
||||
const UsageRoute = UsageRouteImport.update({
|
||||
id: '/usage',
|
||||
|
|
@ -83,11 +86,27 @@ const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
|||
path: '/$threadId',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
const AgentsAutomationsIndexRoute = AgentsAutomationsIndexRouteImport.update({
|
||||
id: '/automations/',
|
||||
path: '/automations/',
|
||||
getParentRoute: () => AgentsRoute,
|
||||
} as any)
|
||||
const ReviewRepositoriesOwnerRoute = ReviewRepositoriesOwnerRouteImport.update({
|
||||
id: '/review_/repositories/$owner',
|
||||
path: '/review/repositories/$owner',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} 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 {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -102,7 +121,10 @@ export interface FileRoutesByFullPath {
|
|||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||
'/review/styles': typeof ReviewStylesRoute
|
||||
'/agents/': typeof AgentsIndexRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
'/agents/automations/': typeof AgentsAutomationsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -116,7 +138,10 @@ export interface FileRoutesByTo {
|
|||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||
'/review/styles': typeof ReviewStylesRoute
|
||||
'/agents': typeof AgentsIndexRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
'/agents/automations': typeof AgentsAutomationsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
|
|
@ -132,7 +157,10 @@ export interface FileRoutesById {
|
|||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||
'/review_/styles': typeof ReviewStylesRoute
|
||||
'/agents/': typeof AgentsIndexRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
|
||||
'/agents/automations/': typeof AgentsAutomationsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
|
|
@ -149,7 +177,10 @@ export interface FileRouteTypes {
|
|||
| '/agents/$threadId'
|
||||
| '/review/styles'
|
||||
| '/agents/'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
| '/review/repositories/$owner'
|
||||
| '/agents/automations/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
|
|
@ -163,7 +194,10 @@ export interface FileRouteTypes {
|
|||
| '/agents/$threadId'
|
||||
| '/review/styles'
|
||||
| '/agents'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
| '/review/repositories/$owner'
|
||||
| '/agents/automations'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
|
|
@ -178,7 +212,10 @@ export interface FileRouteTypes {
|
|||
| '/agents/$threadId'
|
||||
| '/review_/styles'
|
||||
| '/agents/'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
| '/review_/repositories/$owner'
|
||||
| '/agents/automations/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
|
|
@ -197,13 +234,6 @@ export interface RootRouteChildren {
|
|||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/review': {
|
||||
id: '/review'
|
||||
path: '/review'
|
||||
fullPath: '/review'
|
||||
preLoaderRoute: typeof ReviewRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/usage': {
|
||||
id: '/usage'
|
||||
path: '/usage'
|
||||
|
|
@ -211,6 +241,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof UsageRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/review': {
|
||||
id: '/review'
|
||||
path: '/review'
|
||||
fullPath: '/review'
|
||||
preLoaderRoute: typeof ReviewRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/my-settings': {
|
||||
id: '/my-settings'
|
||||
path: '/my-settings'
|
||||
|
|
@ -281,6 +318,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof AgentsThreadIdRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
'/agents/automations/': {
|
||||
id: '/agents/automations/'
|
||||
path: '/automations'
|
||||
fullPath: '/agents/automations/'
|
||||
preLoaderRoute: typeof AgentsAutomationsIndexRouteImport
|
||||
parentRoute: typeof AgentsRoute
|
||||
}
|
||||
'/review_/repositories/$owner': {
|
||||
id: '/review_/repositories/$owner'
|
||||
path: '/review/repositories/$owner'
|
||||
|
|
@ -288,17 +332,37 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof ReviewRepositoriesOwnerRouteImport
|
||||
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 {
|
||||
AgentsThreadIdRoute: typeof AgentsThreadIdRoute
|
||||
AgentsIndexRoute: typeof AgentsIndexRoute
|
||||
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
|
||||
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
|
||||
AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute
|
||||
}
|
||||
|
||||
const AgentsRouteChildren: AgentsRouteChildren = {
|
||||
AgentsThreadIdRoute: AgentsThreadIdRoute,
|
||||
AgentsIndexRoute: AgentsIndexRoute,
|
||||
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
|
||||
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
|
||||
AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute,
|
||||
}
|
||||
|
||||
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