mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
feat: add time-based agent scheduling with APScheduler
Add comprehensive scheduling system that allows agents to automatically
start and stop during configured time windows, helping users manage
Claude API token limits by running agents during off-hours.
Backend Changes:
- Add Schedule and ScheduleOverride database models for persistent storage
- Implement APScheduler-based SchedulerService with UTC timezone support
- Add schedule CRUD API endpoints (/api/projects/{name}/schedules)
- Add manual override tracking to prevent unwanted auto-start/stop
- Integrate scheduler lifecycle with FastAPI startup/shutdown
- Fix timezone bug: explicitly set timezone=timezone.utc on CronTrigger
to ensure correct UTC scheduling (critical fix)
Frontend Changes:
- Add ScheduleModal component for creating and managing schedules
- Add clock button and schedule status display to AgentControl
- Add timezone utilities for converting between UTC and local time
- Add React Query hooks for schedule data fetching
- Fix 204 No Content handling in fetchJSON for delete operations
- Invalidate nextRun cache when manually stopping agent during window
- Add TypeScript type annotations to Terminal component callbacks
Features:
- Multiple overlapping schedules per project supported
- Auto-start at scheduled time via APScheduler cron jobs
- Auto-stop after configured duration
- Manual start/stop creates persistent overrides in database
- Crash recovery with exponential backoff (max 3 retries)
- Server restart preserves schedules and active overrides
- Times displayed in user's local timezone, stored as UTC
- Immediate start if schedule created during active window
Dependencies:
- Add APScheduler for reliable cron-like scheduling
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
119
api/database.py
119
api/database.py
@@ -6,12 +6,13 @@ SQLite database schema for feature storage using SQLAlchemy.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine, text
|
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, create_engine, text
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, relationship, sessionmaker
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
@@ -59,6 +60,91 @@ class Feature(Base):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class Schedule(Base):
|
||||||
|
"""Time-based schedule for automated agent start/stop."""
|
||||||
|
|
||||||
|
__tablename__ = "schedules"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
project_name = Column(String(50), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Timing (stored in UTC)
|
||||||
|
start_time = Column(String(5), nullable=False) # "HH:MM" format
|
||||||
|
duration_minutes = Column(Integer, nullable=False) # 1-1440
|
||||||
|
|
||||||
|
# Day filtering (bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64)
|
||||||
|
days_of_week = Column(Integer, nullable=False, default=127) # 127 = all days
|
||||||
|
|
||||||
|
# State
|
||||||
|
enabled = Column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
|
||||||
|
# Agent configuration for scheduled runs
|
||||||
|
yolo_mode = Column(Boolean, nullable=False, default=False)
|
||||||
|
model = Column(String(50), nullable=True) # None = use global default
|
||||||
|
|
||||||
|
# Crash recovery tracking
|
||||||
|
crash_count = Column(Integer, nullable=False, default=0) # Resets at window start
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
overrides = relationship(
|
||||||
|
"ScheduleOverride", back_populates="schedule", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert schedule to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"project_name": self.project_name,
|
||||||
|
"start_time": self.start_time,
|
||||||
|
"duration_minutes": self.duration_minutes,
|
||||||
|
"days_of_week": self.days_of_week,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"yolo_mode": self.yolo_mode,
|
||||||
|
"model": self.model,
|
||||||
|
"crash_count": self.crash_count,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_active_on_day(self, weekday: int) -> bool:
|
||||||
|
"""Check if schedule is active on given weekday (0=Monday, 6=Sunday)."""
|
||||||
|
day_bit = 1 << weekday
|
||||||
|
return bool(self.days_of_week & day_bit)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleOverride(Base):
|
||||||
|
"""Persisted manual override for a schedule window."""
|
||||||
|
|
||||||
|
__tablename__ = "schedule_overrides"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
schedule_id = Column(
|
||||||
|
Integer, ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override details
|
||||||
|
override_type = Column(String(10), nullable=False) # "start" or "stop"
|
||||||
|
expires_at = Column(DateTime, nullable=False) # When this window ends (UTC)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
schedule = relationship("Schedule", back_populates="overrides")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert override to dictionary for JSON serialization."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"schedule_id": self.schedule_id,
|
||||||
|
"override_type": self.override_type,
|
||||||
|
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_database_path(project_dir: Path) -> Path:
|
def get_database_path(project_dir: Path) -> Path:
|
||||||
"""Return the path to the SQLite database for a project."""
|
"""Return the path to the SQLite database for a project."""
|
||||||
return project_dir / "features.db"
|
return project_dir / "features.db"
|
||||||
@@ -164,6 +250,32 @@ def _is_network_path(path: Path) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_add_schedules_tables(engine) -> None:
|
||||||
|
"""Create schedules and schedule_overrides tables if they don't exist."""
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
inspector = inspect(engine)
|
||||||
|
existing_tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
# Create schedules table if missing
|
||||||
|
if "schedules" not in existing_tables:
|
||||||
|
Schedule.__table__.create(bind=engine)
|
||||||
|
|
||||||
|
# Create schedule_overrides table if missing
|
||||||
|
if "schedule_overrides" not in existing_tables:
|
||||||
|
ScheduleOverride.__table__.create(bind=engine)
|
||||||
|
|
||||||
|
# Add crash_count column if missing (for upgrades)
|
||||||
|
if "schedules" in existing_tables:
|
||||||
|
columns = [c["name"] for c in inspector.get_columns("schedules")]
|
||||||
|
if "crash_count" not in columns:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
text("ALTER TABLE schedules ADD COLUMN crash_count INTEGER DEFAULT 0")
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def create_database(project_dir: Path) -> tuple:
|
def create_database(project_dir: Path) -> tuple:
|
||||||
"""
|
"""
|
||||||
Create database and return engine + session maker.
|
Create database and return engine + session maker.
|
||||||
@@ -196,6 +308,9 @@ def create_database(project_dir: Path) -> tuple:
|
|||||||
_migrate_fix_null_boolean_fields(engine)
|
_migrate_fix_null_boolean_fields(engine)
|
||||||
_migrate_add_dependencies_column(engine)
|
_migrate_add_dependencies_column(engine)
|
||||||
|
|
||||||
|
# Migrate to add schedules tables
|
||||||
|
_migrate_add_schedules_tables(engine)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
return engine, SessionLocal
|
return engine, SessionLocal
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ websockets>=13.0
|
|||||||
python-multipart>=0.0.17
|
python-multipart>=0.0.17
|
||||||
psutil>=6.0.0
|
psutil>=6.0.0
|
||||||
aiofiles>=24.0.0
|
aiofiles>=24.0.0
|
||||||
|
apscheduler>=3.10.0
|
||||||
pywinpty>=2.0.0; sys_platform == "win32"
|
pywinpty>=2.0.0; sys_platform == "win32"
|
||||||
|
|
||||||
# Dev dependencies
|
# Dev dependencies
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from .routers import (
|
|||||||
features_router,
|
features_router,
|
||||||
filesystem_router,
|
filesystem_router,
|
||||||
projects_router,
|
projects_router,
|
||||||
|
schedules_router,
|
||||||
settings_router,
|
settings_router,
|
||||||
spec_creation_router,
|
spec_creation_router,
|
||||||
terminal_router,
|
terminal_router,
|
||||||
@@ -41,6 +42,7 @@ from .services.dev_server_manager import (
|
|||||||
)
|
)
|
||||||
from .services.expand_chat_session import cleanup_all_expand_sessions
|
from .services.expand_chat_session import cleanup_all_expand_sessions
|
||||||
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
|
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
|
||||||
|
from .services.scheduler_service import cleanup_scheduler, get_scheduler
|
||||||
from .services.terminal_manager import cleanup_all_terminals
|
from .services.terminal_manager import cleanup_all_terminals
|
||||||
from .websocket import project_websocket
|
from .websocket import project_websocket
|
||||||
|
|
||||||
@@ -55,8 +57,16 @@ async def lifespan(app: FastAPI):
|
|||||||
# Startup - clean up orphaned lock files from previous runs
|
# Startup - clean up orphaned lock files from previous runs
|
||||||
cleanup_orphaned_locks()
|
cleanup_orphaned_locks()
|
||||||
cleanup_orphaned_devserver_locks()
|
cleanup_orphaned_devserver_locks()
|
||||||
|
|
||||||
|
# Start the scheduler service
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
await scheduler.start()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# Shutdown - cleanup all running agents, sessions, terminals, and dev servers
|
|
||||||
|
# Shutdown - cleanup scheduler first to stop triggering new starts
|
||||||
|
await cleanup_scheduler()
|
||||||
|
# Then cleanup all running agents, sessions, terminals, and dev servers
|
||||||
await cleanup_all_managers()
|
await cleanup_all_managers()
|
||||||
await cleanup_assistant_sessions()
|
await cleanup_assistant_sessions()
|
||||||
await cleanup_all_expand_sessions()
|
await cleanup_all_expand_sessions()
|
||||||
@@ -110,6 +120,7 @@ async def require_localhost(request: Request, call_next):
|
|||||||
app.include_router(projects_router)
|
app.include_router(projects_router)
|
||||||
app.include_router(features_router)
|
app.include_router(features_router)
|
||||||
app.include_router(agent_router)
|
app.include_router(agent_router)
|
||||||
|
app.include_router(schedules_router)
|
||||||
app.include_router(devserver_router)
|
app.include_router(devserver_router)
|
||||||
app.include_router(spec_creation_router)
|
app.include_router(spec_creation_router)
|
||||||
app.include_router(expand_project_router)
|
app.include_router(expand_project_router)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
|
|||||||
from .features import router as features_router
|
from .features import router as features_router
|
||||||
from .filesystem import router as filesystem_router
|
from .filesystem import router as filesystem_router
|
||||||
from .projects import router as projects_router
|
from .projects import router as projects_router
|
||||||
|
from .schedules import router as schedules_router
|
||||||
from .settings import router as settings_router
|
from .settings import router as settings_router
|
||||||
from .spec_creation import router as spec_creation_router
|
from .spec_creation import router as spec_creation_router
|
||||||
from .terminal import router as terminal_router
|
from .terminal import router as terminal_router
|
||||||
@@ -20,6 +21,7 @@ __all__ = [
|
|||||||
"projects_router",
|
"projects_router",
|
||||||
"features_router",
|
"features_router",
|
||||||
"agent_router",
|
"agent_router",
|
||||||
|
"schedules_router",
|
||||||
"devserver_router",
|
"devserver_router",
|
||||||
"spec_creation_router",
|
"spec_creation_router",
|
||||||
"expand_project_router",
|
"expand_project_router",
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ async def start_agent(
|
|||||||
count_testing_in_concurrency=count_testing,
|
count_testing_in_concurrency=count_testing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Notify scheduler of manual start (to prevent auto-stop during scheduled window)
|
||||||
|
if success:
|
||||||
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
if project_dir:
|
||||||
|
get_scheduler().notify_manual_start(project_name, project_dir)
|
||||||
|
|
||||||
return AgentActionResponse(
|
return AgentActionResponse(
|
||||||
success=success,
|
success=success,
|
||||||
status=manager.status,
|
status=manager.status,
|
||||||
@@ -144,6 +151,13 @@ async def stop_agent(project_name: str):
|
|||||||
|
|
||||||
success, message = await manager.stop()
|
success, message = await manager.stop()
|
||||||
|
|
||||||
|
# Notify scheduler of manual stop (to prevent auto-start during scheduled window)
|
||||||
|
if success:
|
||||||
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
project_dir = _get_project_path(project_name)
|
||||||
|
if project_dir:
|
||||||
|
get_scheduler().notify_manual_stop(project_name, project_dir)
|
||||||
|
|
||||||
return AgentActionResponse(
|
return AgentActionResponse(
|
||||||
success=success,
|
success=success,
|
||||||
status=manager.status,
|
status=manager.status,
|
||||||
|
|||||||
417
server/routers/schedules.py
Normal file
417
server/routers/schedules.py
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
"""
|
||||||
|
Schedules Router
|
||||||
|
================
|
||||||
|
|
||||||
|
API endpoints for managing agent schedules.
|
||||||
|
Provides CRUD operations for time-based schedule configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from ..schemas import (
|
||||||
|
NextRunResponse,
|
||||||
|
ScheduleCreate,
|
||||||
|
ScheduleListResponse,
|
||||||
|
ScheduleResponse,
|
||||||
|
ScheduleUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_project_path(project_name: str) -> Path:
|
||||||
|
"""Get project path from registry."""
|
||||||
|
root = Path(__file__).parent.parent.parent
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
|
||||||
|
from registry import get_project_path
|
||||||
|
return get_project_path(project_name)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/projects/{project_name}/schedules",
|
||||||
|
tags=["schedules"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_project_name(name: str) -> str:
|
||||||
|
"""Validate and sanitize project name to prevent path traversal."""
|
||||||
|
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Invalid project name"
|
||||||
|
)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db_session(project_name: str):
|
||||||
|
"""Get database session for a project."""
|
||||||
|
from api.database import create_database
|
||||||
|
|
||||||
|
project_name = validate_project_name(project_name)
|
||||||
|
project_path = _get_project_path(project_name)
|
||||||
|
|
||||||
|
if not project_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Project '{project_name}' not found in registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not project_path.exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Project directory not found: {project_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
_, SessionLocal = create_database(project_path)
|
||||||
|
return SessionLocal(), project_path
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ScheduleListResponse)
|
||||||
|
async def list_schedules(project_name: str):
|
||||||
|
"""Get all schedules for a project."""
|
||||||
|
from api.database import Schedule
|
||||||
|
|
||||||
|
db, _ = _get_db_session(project_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedules = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name
|
||||||
|
).order_by(Schedule.start_time).all()
|
||||||
|
|
||||||
|
return ScheduleListResponse(
|
||||||
|
schedules=[
|
||||||
|
ScheduleResponse(
|
||||||
|
id=s.id,
|
||||||
|
project_name=s.project_name,
|
||||||
|
start_time=s.start_time,
|
||||||
|
duration_minutes=s.duration_minutes,
|
||||||
|
days_of_week=s.days_of_week,
|
||||||
|
enabled=s.enabled,
|
||||||
|
yolo_mode=s.yolo_mode,
|
||||||
|
model=s.model,
|
||||||
|
crash_count=s.crash_count,
|
||||||
|
created_at=s.created_at,
|
||||||
|
)
|
||||||
|
for s in schedules
|
||||||
|
]
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ScheduleResponse, status_code=201)
|
||||||
|
async def create_schedule(project_name: str, data: ScheduleCreate):
|
||||||
|
"""Create a new schedule for a project."""
|
||||||
|
from api.database import Schedule
|
||||||
|
|
||||||
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
|
db, project_path = _get_db_session(project_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create schedule record
|
||||||
|
schedule = Schedule(
|
||||||
|
project_name=project_name,
|
||||||
|
start_time=data.start_time,
|
||||||
|
duration_minutes=data.duration_minutes,
|
||||||
|
days_of_week=data.days_of_week,
|
||||||
|
enabled=data.enabled,
|
||||||
|
yolo_mode=data.yolo_mode,
|
||||||
|
model=data.model,
|
||||||
|
)
|
||||||
|
db.add(schedule)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(schedule)
|
||||||
|
|
||||||
|
# Register with APScheduler if enabled
|
||||||
|
if schedule.enabled:
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
await scheduler.add_schedule(project_name, schedule, project_path)
|
||||||
|
logger.info(f"Registered schedule {schedule.id} with APScheduler")
|
||||||
|
|
||||||
|
# Check if we're currently within this schedule's window
|
||||||
|
# If so, start the agent immediately (cron won't trigger until next occurrence)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
is_within = scheduler._is_within_window(schedule, now)
|
||||||
|
logger.info(f"Schedule {schedule.id}: is_within_window={is_within}, now={now}, start={schedule.start_time}")
|
||||||
|
|
||||||
|
if is_within:
|
||||||
|
# Check for manual stop override
|
||||||
|
from api.database import ScheduleOverride
|
||||||
|
override = db.query(ScheduleOverride).filter(
|
||||||
|
ScheduleOverride.schedule_id == schedule.id,
|
||||||
|
ScheduleOverride.override_type == "stop",
|
||||||
|
ScheduleOverride.expires_at > now,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
logger.info(f"Schedule {schedule.id}: has_override={override is not None}")
|
||||||
|
|
||||||
|
if not override:
|
||||||
|
# Start agent immediately
|
||||||
|
logger.info(
|
||||||
|
f"Schedule {schedule.id} is within active window, starting agent immediately"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await scheduler._start_agent(project_name, project_path, schedule)
|
||||||
|
logger.info(f"Successfully started agent for schedule {schedule.id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start agent for schedule {schedule.id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return ScheduleResponse(
|
||||||
|
id=schedule.id,
|
||||||
|
project_name=schedule.project_name,
|
||||||
|
start_time=schedule.start_time,
|
||||||
|
duration_minutes=schedule.duration_minutes,
|
||||||
|
days_of_week=schedule.days_of_week,
|
||||||
|
enabled=schedule.enabled,
|
||||||
|
yolo_mode=schedule.yolo_mode,
|
||||||
|
model=schedule.model,
|
||||||
|
crash_count=schedule.crash_count,
|
||||||
|
created_at=schedule.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/next", response_model=NextRunResponse)
|
||||||
|
async def get_next_scheduled_run(project_name: str):
|
||||||
|
"""Calculate next scheduled run across all enabled schedules."""
|
||||||
|
from api.database import Schedule, ScheduleOverride
|
||||||
|
|
||||||
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
|
db, _ = _get_db_session(project_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedules = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
Schedule.enabled == True, # noqa: E712
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not schedules:
|
||||||
|
return NextRunResponse(
|
||||||
|
has_schedules=False,
|
||||||
|
next_start=None,
|
||||||
|
next_end=None,
|
||||||
|
is_currently_running=False,
|
||||||
|
active_schedule_count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
|
||||||
|
# Find active schedules and calculate next run
|
||||||
|
active_count = 0
|
||||||
|
next_start = None
|
||||||
|
latest_end = None
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
if scheduler._is_within_window(schedule, now):
|
||||||
|
# Check for manual stop override
|
||||||
|
override = db.query(ScheduleOverride).filter(
|
||||||
|
ScheduleOverride.schedule_id == schedule.id,
|
||||||
|
ScheduleOverride.override_type == "stop",
|
||||||
|
ScheduleOverride.expires_at > now,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not override:
|
||||||
|
# Schedule is active and not manually stopped
|
||||||
|
active_count += 1
|
||||||
|
# Calculate end time for this window
|
||||||
|
end_time = _calculate_window_end(schedule, now)
|
||||||
|
if latest_end is None or end_time > latest_end:
|
||||||
|
latest_end = end_time
|
||||||
|
# If override exists, treat schedule as not active
|
||||||
|
else:
|
||||||
|
# Calculate next start time
|
||||||
|
next_schedule_start = _calculate_next_start(schedule, now)
|
||||||
|
if next_schedule_start and (next_start is None or next_schedule_start < next_start):
|
||||||
|
next_start = next_schedule_start
|
||||||
|
|
||||||
|
return NextRunResponse(
|
||||||
|
has_schedules=True,
|
||||||
|
next_start=next_start if active_count == 0 else None,
|
||||||
|
next_end=latest_end,
|
||||||
|
is_currently_running=active_count > 0,
|
||||||
|
active_schedule_count=active_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{schedule_id}", response_model=ScheduleResponse)
|
||||||
|
async def get_schedule(project_name: str, schedule_id: int):
|
||||||
|
"""Get a single schedule by ID."""
|
||||||
|
from api.database import Schedule
|
||||||
|
|
||||||
|
db, _ = _get_db_session(project_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule = db.query(Schedule).filter(
|
||||||
|
Schedule.id == schedule_id,
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
return ScheduleResponse(
|
||||||
|
id=schedule.id,
|
||||||
|
project_name=schedule.project_name,
|
||||||
|
start_time=schedule.start_time,
|
||||||
|
duration_minutes=schedule.duration_minutes,
|
||||||
|
days_of_week=schedule.days_of_week,
|
||||||
|
enabled=schedule.enabled,
|
||||||
|
yolo_mode=schedule.yolo_mode,
|
||||||
|
model=schedule.model,
|
||||||
|
crash_count=schedule.crash_count,
|
||||||
|
created_at=schedule.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{schedule_id}", response_model=ScheduleResponse)
|
||||||
|
async def update_schedule(
|
||||||
|
project_name: str,
|
||||||
|
schedule_id: int,
|
||||||
|
data: ScheduleUpdate
|
||||||
|
):
|
||||||
|
"""Update an existing schedule."""
|
||||||
|
from api.database import Schedule
|
||||||
|
|
||||||
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
|
db, project_path = _get_db_session(project_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule = db.query(Schedule).filter(
|
||||||
|
Schedule.id == schedule_id,
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
was_enabled = schedule.enabled
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
if data.start_time is not None:
|
||||||
|
schedule.start_time = data.start_time
|
||||||
|
if data.duration_minutes is not None:
|
||||||
|
schedule.duration_minutes = data.duration_minutes
|
||||||
|
if data.days_of_week is not None:
|
||||||
|
schedule.days_of_week = data.days_of_week
|
||||||
|
if data.enabled is not None:
|
||||||
|
schedule.enabled = data.enabled
|
||||||
|
if data.yolo_mode is not None:
|
||||||
|
schedule.yolo_mode = data.yolo_mode
|
||||||
|
if data.model is not None:
|
||||||
|
schedule.model = data.model
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(schedule)
|
||||||
|
|
||||||
|
# Update APScheduler jobs
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
if schedule.enabled:
|
||||||
|
# Re-register with updated times
|
||||||
|
await scheduler.add_schedule(project_name, schedule, project_path)
|
||||||
|
elif was_enabled:
|
||||||
|
# Was enabled, now disabled - remove jobs
|
||||||
|
scheduler.remove_schedule(schedule_id)
|
||||||
|
|
||||||
|
return ScheduleResponse(
|
||||||
|
id=schedule.id,
|
||||||
|
project_name=schedule.project_name,
|
||||||
|
start_time=schedule.start_time,
|
||||||
|
duration_minutes=schedule.duration_minutes,
|
||||||
|
days_of_week=schedule.days_of_week,
|
||||||
|
enabled=schedule.enabled,
|
||||||
|
yolo_mode=schedule.yolo_mode,
|
||||||
|
model=schedule.model,
|
||||||
|
crash_count=schedule.crash_count,
|
||||||
|
created_at=schedule.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{schedule_id}", status_code=204)
|
||||||
|
async def delete_schedule(project_name: str, schedule_id: int):
|
||||||
|
"""Delete a schedule."""
|
||||||
|
from api.database import Schedule
|
||||||
|
|
||||||
|
from ..services.scheduler_service import get_scheduler
|
||||||
|
|
||||||
|
db, _ = _get_db_session(project_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule = db.query(Schedule).filter(
|
||||||
|
Schedule.id == schedule_id,
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
# Remove APScheduler jobs
|
||||||
|
scheduler = get_scheduler()
|
||||||
|
scheduler.remove_schedule(schedule_id)
|
||||||
|
|
||||||
|
# Delete from database
|
||||||
|
db.delete(schedule)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_window_end(schedule, now: datetime) -> datetime:
|
||||||
|
"""Calculate when the current window ends."""
|
||||||
|
start_hour, start_minute = map(int, schedule.start_time.split(":"))
|
||||||
|
|
||||||
|
# Create start time for today in UTC
|
||||||
|
window_start = now.replace(
|
||||||
|
hour=start_hour, minute=start_minute, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# If current time is before start time, the window started yesterday
|
||||||
|
if now < window_start:
|
||||||
|
window_start = window_start - timedelta(days=1)
|
||||||
|
|
||||||
|
return window_start + timedelta(minutes=schedule.duration_minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_next_start(schedule, now: datetime) -> datetime | None:
|
||||||
|
"""Calculate the next start time for a schedule."""
|
||||||
|
start_hour, start_minute = map(int, schedule.start_time.split(":"))
|
||||||
|
|
||||||
|
# Create start time for today
|
||||||
|
candidate = now.replace(
|
||||||
|
hour=start_hour, minute=start_minute, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# If already past today's start time, check tomorrow
|
||||||
|
if candidate <= now:
|
||||||
|
candidate = candidate + timedelta(days=1)
|
||||||
|
|
||||||
|
# Find the next active day
|
||||||
|
for _ in range(7):
|
||||||
|
if schedule.is_active_on_day(candidate.weekday()):
|
||||||
|
return candidate
|
||||||
|
candidate = candidate + timedelta(days=1)
|
||||||
|
|
||||||
|
return None
|
||||||
@@ -472,3 +472,92 @@ class WSDevServerStatusMessage(BaseModel):
|
|||||||
type: Literal["dev_server_status"] = "dev_server_status"
|
type: Literal["dev_server_status"] = "dev_server_status"
|
||||||
status: Literal["stopped", "running", "crashed"]
|
status: Literal["stopped", "running", "crashed"]
|
||||||
url: str | None = None
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Schedule Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleCreate(BaseModel):
|
||||||
|
"""Request schema for creating a schedule."""
|
||||||
|
start_time: str = Field(
|
||||||
|
...,
|
||||||
|
pattern=r'^([0-1][0-9]|2[0-3]):[0-5][0-9]$',
|
||||||
|
description="Start time in HH:MM format (local time, will be stored as UTC)"
|
||||||
|
)
|
||||||
|
duration_minutes: int = Field(
|
||||||
|
...,
|
||||||
|
ge=1,
|
||||||
|
le=1440,
|
||||||
|
description="Duration in minutes (1-1440)"
|
||||||
|
)
|
||||||
|
days_of_week: int = Field(
|
||||||
|
default=127,
|
||||||
|
ge=0,
|
||||||
|
le=127,
|
||||||
|
description="Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64"
|
||||||
|
)
|
||||||
|
enabled: bool = True
|
||||||
|
yolo_mode: bool = False
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
@field_validator('model')
|
||||||
|
@classmethod
|
||||||
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
|
"""Validate model is in the allowed list."""
|
||||||
|
if v is not None and v not in VALID_MODELS:
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleUpdate(BaseModel):
|
||||||
|
"""Request schema for updating a schedule (partial updates allowed)."""
|
||||||
|
start_time: str | None = Field(
|
||||||
|
None,
|
||||||
|
pattern=r'^([0-1][0-9]|2[0-3]):[0-5][0-9]$'
|
||||||
|
)
|
||||||
|
duration_minutes: int | None = Field(None, ge=1, le=1440)
|
||||||
|
days_of_week: int | None = Field(None, ge=0, le=127)
|
||||||
|
enabled: bool | None = None
|
||||||
|
yolo_mode: bool | None = None
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
@field_validator('model')
|
||||||
|
@classmethod
|
||||||
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
|
"""Validate model is in the allowed list."""
|
||||||
|
if v is not None and v not in VALID_MODELS:
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleResponse(BaseModel):
|
||||||
|
"""Response schema for a schedule."""
|
||||||
|
id: int
|
||||||
|
project_name: str
|
||||||
|
start_time: str # UTC, frontend converts to local
|
||||||
|
duration_minutes: int
|
||||||
|
days_of_week: int
|
||||||
|
enabled: bool
|
||||||
|
yolo_mode: bool
|
||||||
|
model: str | None
|
||||||
|
crash_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleListResponse(BaseModel):
|
||||||
|
"""Response containing list of schedules."""
|
||||||
|
schedules: list[ScheduleResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class NextRunResponse(BaseModel):
|
||||||
|
"""Response for next scheduled run calculation."""
|
||||||
|
has_schedules: bool
|
||||||
|
next_start: datetime | None # UTC
|
||||||
|
next_end: datetime | None # UTC (latest end if overlapping)
|
||||||
|
is_currently_running: bool
|
||||||
|
active_schedule_count: int
|
||||||
|
|||||||
602
server/services/scheduler_service.py
Normal file
602
server/services/scheduler_service.py
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
"""
|
||||||
|
Agent Scheduler Service
|
||||||
|
=======================
|
||||||
|
|
||||||
|
APScheduler-based service for automated agent scheduling.
|
||||||
|
Manages time-based start/stop of agents with crash recovery and manual override tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
|
||||||
|
# Add parent directory for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
MAX_CRASH_RETRIES = 3
|
||||||
|
CRASH_BACKOFF_BASE = 10 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerService:
|
||||||
|
"""
|
||||||
|
APScheduler-based service for automated agent scheduling.
|
||||||
|
|
||||||
|
Creates two jobs per schedule:
|
||||||
|
1. Start job - triggers at start_time on configured days
|
||||||
|
2. Stop job - triggers at start_time + duration on configured days
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- Manual override tracking (persisted to DB)
|
||||||
|
- Crash recovery with exponential backoff
|
||||||
|
- Overlapping schedules (latest stop wins)
|
||||||
|
- Server restart recovery
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
from datetime import timezone as dt_timezone
|
||||||
|
|
||||||
|
# CRITICAL: Use UTC timezone since all schedule times are stored in UTC
|
||||||
|
self.scheduler = AsyncIOScheduler(timezone=dt_timezone.utc)
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the scheduler and load all existing schedules."""
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.scheduler.start()
|
||||||
|
self._started = True
|
||||||
|
logger.info("Scheduler service started")
|
||||||
|
|
||||||
|
# Check for active schedule windows on startup
|
||||||
|
await self._check_missed_windows_on_startup()
|
||||||
|
|
||||||
|
# Load all schedules from registered projects
|
||||||
|
await self._load_all_schedules()
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Shutdown the scheduler gracefully."""
|
||||||
|
if not self._started:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.scheduler.shutdown(wait=False)
|
||||||
|
self._started = False
|
||||||
|
logger.info("Scheduler service stopped")
|
||||||
|
|
||||||
|
async def _load_all_schedules(self):
|
||||||
|
"""Load schedules for all registered projects."""
|
||||||
|
from registry import list_registered_projects
|
||||||
|
|
||||||
|
try:
|
||||||
|
projects = list_registered_projects()
|
||||||
|
total_loaded = 0
|
||||||
|
for project_name, info in projects.items():
|
||||||
|
project_path = Path(info.get("path", ""))
|
||||||
|
if project_path.exists():
|
||||||
|
count = await self._load_project_schedules(project_name, project_path)
|
||||||
|
total_loaded += count
|
||||||
|
if total_loaded > 0:
|
||||||
|
logger.info(f"Loaded {total_loaded} schedule(s) across all projects")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading schedules: {e}")
|
||||||
|
|
||||||
|
async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int:
|
||||||
|
"""Load schedules for a single project. Returns count of schedules loaded."""
|
||||||
|
from api.database import Schedule, create_database
|
||||||
|
|
||||||
|
db_path = project_dir / "features.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, SessionLocal = create_database(project_dir)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
schedules = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
Schedule.enabled == True, # noqa: E712
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
await self.add_schedule(project_name, schedule, project_dir)
|
||||||
|
|
||||||
|
if schedules:
|
||||||
|
logger.info(f"Loaded {len(schedules)} schedule(s) for project '{project_name}'")
|
||||||
|
return len(schedules)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading schedules for {project_name}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
async def add_schedule(self, project_name: str, schedule, project_dir: Path):
|
||||||
|
"""Create APScheduler jobs for a schedule."""
|
||||||
|
try:
|
||||||
|
# Convert days bitfield to cron day_of_week string
|
||||||
|
days = self._bitfield_to_cron_days(schedule.days_of_week)
|
||||||
|
|
||||||
|
# Parse start time
|
||||||
|
hour, minute = map(int, schedule.start_time.split(":"))
|
||||||
|
|
||||||
|
# Calculate end time
|
||||||
|
start_dt = datetime.strptime(schedule.start_time, "%H:%M")
|
||||||
|
end_dt = start_dt + timedelta(minutes=schedule.duration_minutes)
|
||||||
|
|
||||||
|
# Handle midnight wraparound for end time
|
||||||
|
end_hour = end_dt.hour
|
||||||
|
end_minute = end_dt.minute
|
||||||
|
|
||||||
|
# Start job - CRITICAL: timezone=timezone.utc is required for correct UTC scheduling
|
||||||
|
start_job_id = f"schedule_{schedule.id}_start"
|
||||||
|
start_trigger = CronTrigger(hour=hour, minute=minute, day_of_week=days, timezone=timezone.utc)
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._handle_scheduled_start,
|
||||||
|
start_trigger,
|
||||||
|
id=start_job_id,
|
||||||
|
args=[project_name, schedule.id, str(project_dir)],
|
||||||
|
replace_existing=True,
|
||||||
|
misfire_grace_time=300, # 5 minutes grace period
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stop job - CRITICAL: timezone=timezone.utc is required for correct UTC scheduling
|
||||||
|
stop_job_id = f"schedule_{schedule.id}_stop"
|
||||||
|
stop_trigger = CronTrigger(hour=end_hour, minute=end_minute, day_of_week=days, timezone=timezone.utc)
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._handle_scheduled_stop,
|
||||||
|
stop_trigger,
|
||||||
|
id=stop_job_id,
|
||||||
|
args=[project_name, schedule.id, str(project_dir)],
|
||||||
|
replace_existing=True,
|
||||||
|
misfire_grace_time=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log next run times for monitoring
|
||||||
|
start_job = self.scheduler.get_job(start_job_id)
|
||||||
|
stop_job = self.scheduler.get_job(stop_job_id)
|
||||||
|
logger.info(
|
||||||
|
f"Registered schedule {schedule.id} for {project_name}: "
|
||||||
|
f"start at {hour:02d}:{minute:02d} UTC (next: {start_job.next_run_time}), "
|
||||||
|
f"stop at {end_hour:02d}:{end_minute:02d} UTC (next: {stop_job.next_run_time})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding schedule {schedule.id}: {e}")
|
||||||
|
|
||||||
|
def remove_schedule(self, schedule_id: int):
|
||||||
|
"""Remove APScheduler jobs for a schedule."""
|
||||||
|
start_job_id = f"schedule_{schedule_id}_start"
|
||||||
|
stop_job_id = f"schedule_{schedule_id}_stop"
|
||||||
|
|
||||||
|
removed = []
|
||||||
|
try:
|
||||||
|
self.scheduler.remove_job(start_job_id)
|
||||||
|
removed.append("start")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.scheduler.remove_job(stop_job_id)
|
||||||
|
removed.append("stop")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
logger.info(f"Removed schedule {schedule_id} jobs: {', '.join(removed)}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"No jobs found to remove for schedule {schedule_id}")
|
||||||
|
|
||||||
|
async def _handle_scheduled_start(
|
||||||
|
self, project_name: str, schedule_id: int, project_dir_str: str
|
||||||
|
):
|
||||||
|
"""Handle scheduled agent start."""
|
||||||
|
logger.info(f"Scheduled start triggered for {project_name} (schedule {schedule_id})")
|
||||||
|
project_dir = Path(project_dir_str)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.database import Schedule, ScheduleOverride, create_database
|
||||||
|
|
||||||
|
_, SessionLocal = create_database(project_dir)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
if not schedule or not schedule.enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for manual stop override
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
override = db.query(ScheduleOverride).filter(
|
||||||
|
ScheduleOverride.schedule_id == schedule_id,
|
||||||
|
ScheduleOverride.override_type == "stop",
|
||||||
|
ScheduleOverride.expires_at > now,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if override:
|
||||||
|
logger.info(
|
||||||
|
f"Skipping scheduled start for {project_name}: "
|
||||||
|
f"manual stop override active until {override.expires_at}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reset crash count at window start
|
||||||
|
schedule.crash_count = 0
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Start agent
|
||||||
|
await self._start_agent(project_name, project_dir, schedule)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in scheduled start for {project_name}: {e}")
|
||||||
|
|
||||||
|
async def _handle_scheduled_stop(
|
||||||
|
self, project_name: str, schedule_id: int, project_dir_str: str
|
||||||
|
):
|
||||||
|
"""Handle scheduled agent stop."""
|
||||||
|
logger.info(f"Scheduled stop triggered for {project_name} (schedule {schedule_id})")
|
||||||
|
project_dir = Path(project_dir_str)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from api.database import Schedule, ScheduleOverride, create_database
|
||||||
|
|
||||||
|
_, SessionLocal = create_database(project_dir)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
|
||||||
|
if not schedule:
|
||||||
|
logger.warning(f"Schedule {schedule_id} not found in database")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if other schedules are still active (latest stop wins)
|
||||||
|
if self._other_schedules_still_active(db, project_name, schedule_id):
|
||||||
|
logger.info(
|
||||||
|
f"Skipping scheduled stop for {project_name}: "
|
||||||
|
f"other schedules still active (latest stop wins)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Clear expired overrides for this schedule
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
db.query(ScheduleOverride).filter(
|
||||||
|
ScheduleOverride.schedule_id == schedule_id,
|
||||||
|
ScheduleOverride.expires_at <= now,
|
||||||
|
).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Stop agent
|
||||||
|
await self._stop_agent(project_name, project_dir)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in scheduled stop for {project_name}: {e}")
|
||||||
|
|
||||||
|
def _other_schedules_still_active(
|
||||||
|
self, db, project_name: str, ending_schedule_id: int
|
||||||
|
) -> bool:
|
||||||
|
"""Check if any other schedule windows are still active."""
|
||||||
|
from api.database import Schedule
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
schedules = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
Schedule.enabled == True, # noqa: E712
|
||||||
|
Schedule.id != ending_schedule_id,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
if self._is_within_window(schedule, now):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_within_window(self, schedule, now: datetime) -> bool:
|
||||||
|
"""Check if current time is within schedule window."""
|
||||||
|
# Check if active on this day
|
||||||
|
if not schedule.is_active_on_day(now.weekday()):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse schedule times
|
||||||
|
start_hour, start_minute = map(int, schedule.start_time.split(":"))
|
||||||
|
start_time = now.replace(hour=start_hour, minute=start_minute, second=0, microsecond=0)
|
||||||
|
|
||||||
|
# Calculate end time
|
||||||
|
end_time = start_time + timedelta(minutes=schedule.duration_minutes)
|
||||||
|
|
||||||
|
current_time = now.replace(tzinfo=None) if now.tzinfo else now
|
||||||
|
start_time = start_time.replace(tzinfo=None)
|
||||||
|
end_time = end_time.replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Handle midnight wraparound
|
||||||
|
if end_time.day > start_time.day:
|
||||||
|
# Schedule crosses midnight
|
||||||
|
return current_time >= start_time or current_time < end_time.replace(day=start_time.day)
|
||||||
|
else:
|
||||||
|
return start_time <= current_time < end_time
|
||||||
|
|
||||||
|
async def _start_agent(self, project_name: str, project_dir: Path, schedule):
|
||||||
|
"""Start the agent for a project."""
|
||||||
|
from .process_manager import get_manager
|
||||||
|
|
||||||
|
root_dir = Path(__file__).parent.parent.parent
|
||||||
|
manager = get_manager(project_name, project_dir, root_dir)
|
||||||
|
|
||||||
|
if manager.status in ("running", "paused"):
|
||||||
|
logger.info(f"Agent already running for {project_name}, skipping scheduled start")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Starting agent for {project_name} (schedule {schedule.id}, yolo={schedule.yolo_mode})")
|
||||||
|
success, msg = await manager.start(
|
||||||
|
yolo_mode=schedule.yolo_mode,
|
||||||
|
model=schedule.model,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"✓ Agent started successfully for {project_name}")
|
||||||
|
else:
|
||||||
|
logger.error(f"✗ Failed to start agent for {project_name}: {msg}")
|
||||||
|
|
||||||
|
async def _stop_agent(self, project_name: str, project_dir: Path):
|
||||||
|
"""Stop the agent for a project."""
|
||||||
|
from .process_manager import get_manager
|
||||||
|
|
||||||
|
root_dir = Path(__file__).parent.parent.parent
|
||||||
|
manager = get_manager(project_name, project_dir, root_dir)
|
||||||
|
|
||||||
|
if manager.status not in ("running", "paused"):
|
||||||
|
logger.info(f"Agent not running for {project_name}, skipping scheduled stop")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Stopping agent for {project_name} (scheduled)")
|
||||||
|
success, msg = await manager.stop()
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"✓ Agent stopped successfully for {project_name}")
|
||||||
|
else:
|
||||||
|
logger.error(f"✗ Failed to stop agent for {project_name}: {msg}")
|
||||||
|
|
||||||
|
async def handle_crash_during_window(self, project_name: str, project_dir: Path):
|
||||||
|
"""Called when agent crashes. Attempt restart with backoff."""
|
||||||
|
from api.database import Schedule, create_database
|
||||||
|
|
||||||
|
_, SessionLocal = create_database(project_dir)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
schedules = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
Schedule.enabled == True, # noqa: E712
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
if not self._is_within_window(schedule, now):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if schedule.crash_count >= MAX_CRASH_RETRIES:
|
||||||
|
logger.warning(
|
||||||
|
f"Max crash retries ({MAX_CRASH_RETRIES}) reached for "
|
||||||
|
f"schedule {schedule.id} on {project_name}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
schedule.crash_count += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Exponential backoff: 10s, 30s, 90s
|
||||||
|
delay = CRASH_BACKOFF_BASE * (3 ** (schedule.crash_count - 1))
|
||||||
|
logger.info(
|
||||||
|
f"Restarting agent for {project_name} in {delay}s "
|
||||||
|
f"(attempt {schedule.crash_count})"
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await self._start_agent(project_name, project_dir, schedule)
|
||||||
|
return # Only restart once
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def notify_manual_start(self, project_name: str, project_dir: Path):
|
||||||
|
"""Record manual start to prevent auto-stop."""
|
||||||
|
logger.info(f"Manual start detected for {project_name}, creating override to prevent auto-stop")
|
||||||
|
self._create_override_for_active_schedules(project_name, project_dir, "start")
|
||||||
|
|
||||||
|
def notify_manual_stop(self, project_name: str, project_dir: Path):
|
||||||
|
"""Record manual stop to prevent auto-start."""
|
||||||
|
logger.info(f"Manual stop detected for {project_name}, creating override to prevent auto-start")
|
||||||
|
self._create_override_for_active_schedules(project_name, project_dir, "stop")
|
||||||
|
|
||||||
|
def _create_override_for_active_schedules(
|
||||||
|
self, project_name: str, project_dir: Path, override_type: str
|
||||||
|
):
|
||||||
|
"""Create overrides for all active schedule windows."""
|
||||||
|
from api.database import Schedule, ScheduleOverride, create_database
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, SessionLocal = create_database(project_dir)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
schedules = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
Schedule.enabled == True, # noqa: E712
|
||||||
|
).all()
|
||||||
|
|
||||||
|
overrides_created = 0
|
||||||
|
for schedule in schedules:
|
||||||
|
if not self._is_within_window(schedule, now):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate window end time
|
||||||
|
window_end = self._calculate_window_end(schedule, now)
|
||||||
|
|
||||||
|
# Check if override already exists
|
||||||
|
existing = db.query(ScheduleOverride).filter(
|
||||||
|
ScheduleOverride.schedule_id == schedule.id,
|
||||||
|
ScheduleOverride.override_type == override_type,
|
||||||
|
ScheduleOverride.expires_at > now,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create override
|
||||||
|
override = ScheduleOverride(
|
||||||
|
schedule_id=schedule.id,
|
||||||
|
override_type=override_type,
|
||||||
|
expires_at=window_end,
|
||||||
|
)
|
||||||
|
db.add(override)
|
||||||
|
overrides_created += 1
|
||||||
|
logger.info(
|
||||||
|
f"Created '{override_type}' override for schedule {schedule.id} "
|
||||||
|
f"(expires at {window_end})"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
if overrides_created > 0:
|
||||||
|
logger.info(f"Created {overrides_created} override(s) for {project_name}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating override for {project_name}: {e}")
|
||||||
|
|
||||||
|
def _calculate_window_end(self, schedule, now: datetime) -> datetime:
|
||||||
|
"""Calculate when the current window ends."""
|
||||||
|
start_hour, start_minute = map(int, schedule.start_time.split(":"))
|
||||||
|
|
||||||
|
# Create start time for today
|
||||||
|
window_start = now.replace(
|
||||||
|
hour=start_hour, minute=start_minute, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# If current time is before start time, the window started yesterday
|
||||||
|
if now.replace(tzinfo=None) < window_start.replace(tzinfo=None):
|
||||||
|
window_start = window_start - timedelta(days=1)
|
||||||
|
|
||||||
|
window_end = window_start + timedelta(minutes=schedule.duration_minutes)
|
||||||
|
return window_end
|
||||||
|
|
||||||
|
async def _check_missed_windows_on_startup(self):
|
||||||
|
"""Called on server start. Start agents for any active windows."""
|
||||||
|
from registry import list_registered_projects
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
projects = list_registered_projects()
|
||||||
|
|
||||||
|
for project_name, info in projects.items():
|
||||||
|
project_dir = Path(info.get("path", ""))
|
||||||
|
if not project_dir.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
await self._check_project_on_startup(project_name, project_dir, now)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking missed windows on startup: {e}")
|
||||||
|
|
||||||
|
async def _check_project_on_startup(
|
||||||
|
self, project_name: str, project_dir: Path, now: datetime
|
||||||
|
):
|
||||||
|
"""Check if a project should be started on server startup."""
|
||||||
|
from api.database import Schedule, ScheduleOverride, create_database
|
||||||
|
|
||||||
|
db_path = project_dir / "features.db"
|
||||||
|
if not db_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, SessionLocal = create_database(project_dir)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
schedules = db.query(Schedule).filter(
|
||||||
|
Schedule.project_name == project_name,
|
||||||
|
Schedule.enabled == True, # noqa: E712
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
if not self._is_within_window(schedule, now):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for manual stop override
|
||||||
|
override = db.query(ScheduleOverride).filter(
|
||||||
|
ScheduleOverride.schedule_id == schedule.id,
|
||||||
|
ScheduleOverride.override_type == "stop",
|
||||||
|
ScheduleOverride.expires_at > now,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if override:
|
||||||
|
logger.info(
|
||||||
|
f"Skipping startup start for {project_name}: "
|
||||||
|
f"manual stop override active"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start the agent
|
||||||
|
logger.info(
|
||||||
|
f"Starting {project_name} for active schedule {schedule.id} "
|
||||||
|
f"(server startup)"
|
||||||
|
)
|
||||||
|
await self._start_agent(project_name, project_dir, schedule)
|
||||||
|
return # Only start once per project
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking startup for {project_name}: {e}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bitfield_to_cron_days(bitfield: int) -> str:
|
||||||
|
"""Convert days bitfield to APScheduler cron format."""
|
||||||
|
days = []
|
||||||
|
day_map = [
|
||||||
|
(1, "mon"),
|
||||||
|
(2, "tue"),
|
||||||
|
(4, "wed"),
|
||||||
|
(8, "thu"),
|
||||||
|
(16, "fri"),
|
||||||
|
(32, "sat"),
|
||||||
|
(64, "sun"),
|
||||||
|
]
|
||||||
|
for bit, name in day_map:
|
||||||
|
if bitfield & bit:
|
||||||
|
days.append(name)
|
||||||
|
return ",".join(days) if days else "mon-sun"
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
_scheduler: Optional[SchedulerService] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_scheduler() -> SchedulerService:
|
||||||
|
"""Get the global scheduler instance."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = SchedulerService()
|
||||||
|
return _scheduler
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_scheduler():
|
||||||
|
"""Clean up scheduler on shutdown."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is not None:
|
||||||
|
await _scheduler.stop()
|
||||||
|
_scheduler = None
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Play, Square, Loader2, GitBranch } from 'lucide-react'
|
import { Play, Square, Loader2, GitBranch, Clock } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useStartAgent,
|
useStartAgent,
|
||||||
useStopAgent,
|
useStopAgent,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from '../hooks/useProjects'
|
} from '../hooks/useProjects'
|
||||||
|
import { useNextScheduledRun } from '../hooks/useSchedules'
|
||||||
|
import { formatNextRun, formatEndTime } from '../lib/timeUtils'
|
||||||
|
import { ScheduleModal } from './ScheduleModal'
|
||||||
import type { AgentStatus } from '../lib/types'
|
import type { AgentStatus } from '../lib/types'
|
||||||
|
|
||||||
interface AgentControlProps {
|
interface AgentControlProps {
|
||||||
@@ -21,6 +24,9 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
|
|||||||
|
|
||||||
const startAgent = useStartAgent(projectName)
|
const startAgent = useStartAgent(projectName)
|
||||||
const stopAgent = useStopAgent(projectName)
|
const stopAgent = useStopAgent(projectName)
|
||||||
|
const { data: nextRun } = useNextScheduledRun(projectName)
|
||||||
|
|
||||||
|
const [showScheduleModal, setShowScheduleModal] = useState(false)
|
||||||
|
|
||||||
const isLoading = startAgent.isPending || stopAgent.isPending
|
const isLoading = startAgent.isPending || stopAgent.isPending
|
||||||
const isRunning = status === 'running' || status === 'paused'
|
const isRunning = status === 'running' || status === 'paused'
|
||||||
@@ -40,78 +46,113 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
|
|||||||
const isStopped = status === 'stopped' || status === 'crashed'
|
const isStopped = status === 'stopped' || status === 'crashed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<>
|
||||||
{/* Concurrency slider - visible when stopped (not during loading or running) */}
|
<div className="flex items-center gap-4">
|
||||||
{isStopped && (
|
{/* Concurrency slider - visible when stopped */}
|
||||||
<div className="flex items-center gap-2">
|
{isStopped && (
|
||||||
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
|
||||||
type="range"
|
<input
|
||||||
min={1}
|
type="range"
|
||||||
max={5}
|
min={1}
|
||||||
value={concurrency}
|
max={5}
|
||||||
onChange={(e) => setConcurrency(Number(e.target.value))}
|
value={concurrency}
|
||||||
|
onChange={(e) => setConcurrency(Number(e.target.value))}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
|
||||||
|
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
|
||||||
|
aria-label="Set number of concurrent agents"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold min-w-[1.5rem] text-center">
|
||||||
|
{concurrency}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show concurrency indicator when running with multiple agents */}
|
||||||
|
{isRunning && isParallel && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold">
|
||||||
|
<GitBranch size={14} />
|
||||||
|
<span>{concurrency}x</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Schedule status display */}
|
||||||
|
{nextRun?.is_currently_running && nextRun.next_end && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[var(--color-neo-done)] font-bold">
|
||||||
|
<Clock size={16} className="flex-shrink-0" />
|
||||||
|
<span>Running until {formatEndTime(nextRun.next_end)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!nextRun?.is_currently_running && nextRun?.next_start && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-white font-bold">
|
||||||
|
<Clock size={16} className="flex-shrink-0" />
|
||||||
|
<span>Next: {formatNextRun(nextRun.next_start)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start/Stop button */}
|
||||||
|
{isLoadingStatus ? (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="neo-btn text-sm py-2 px-3 opacity-50 cursor-not-allowed"
|
||||||
|
title="Loading agent status..."
|
||||||
|
aria-label="Loading agent status"
|
||||||
|
>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
</button>
|
||||||
|
) : isStopped ? (
|
||||||
|
<button
|
||||||
|
onClick={handleStart}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
|
className={`neo-btn text-sm py-2 px-3 ${
|
||||||
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
|
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success'
|
||||||
aria-label="Set number of concurrent agents"
|
}`}
|
||||||
/>
|
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
|
||||||
<span className="text-xs font-bold min-w-[1.5rem] text-center">
|
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
|
||||||
{concurrency}x
|
>
|
||||||
</span>
|
{isLoading ? (
|
||||||
</div>
|
<Loader2 size={18} className="animate-spin" />
|
||||||
)}
|
) : (
|
||||||
|
<Play size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`neo-btn text-sm py-2 px-3 ${
|
||||||
|
yoloMode ? 'neo-btn-yolo' : 'neo-btn-danger'
|
||||||
|
}`}
|
||||||
|
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
|
||||||
|
aria-label={yoloMode ? 'Stop Agent in YOLO Mode' : 'Stop Agent'}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show concurrency indicator when running with multiple agents */}
|
{/* Clock button to open schedule modal */}
|
||||||
{isRunning && isParallel && (
|
<button
|
||||||
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold">
|
onClick={() => setShowScheduleModal(true)}
|
||||||
<GitBranch size={14} />
|
className="neo-btn text-sm py-2 px-3"
|
||||||
<span>{concurrency}x</span>
|
title="Manage schedules"
|
||||||
</div>
|
aria-label="Manage agent schedules"
|
||||||
)}
|
>
|
||||||
|
<Clock size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoadingStatus ? (
|
{/* Schedule Modal */}
|
||||||
<button
|
<ScheduleModal
|
||||||
disabled
|
projectName={projectName}
|
||||||
className="neo-btn text-sm py-2 px-3 opacity-50 cursor-not-allowed"
|
isOpen={showScheduleModal}
|
||||||
title="Loading agent status..."
|
onClose={() => setShowScheduleModal(false)}
|
||||||
aria-label="Loading agent status"
|
/>
|
||||||
>
|
</>
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
</button>
|
|
||||||
) : isStopped ? (
|
|
||||||
<button
|
|
||||||
onClick={handleStart}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`neo-btn text-sm py-2 px-3 ${
|
|
||||||
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success'
|
|
||||||
}`}
|
|
||||||
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
|
|
||||||
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={handleStop}
|
|
||||||
disabled={isLoading}
|
|
||||||
className={`neo-btn text-sm py-2 px-3 ${
|
|
||||||
yoloMode ? 'neo-btn-yolo' : 'neo-btn-danger'
|
|
||||||
}`}
|
|
||||||
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
|
|
||||||
aria-label={yoloMode ? 'Stop Agent in YOLO Mode' : 'Stop Agent'}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Square size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
397
ui/src/components/ScheduleModal.tsx
Normal file
397
ui/src/components/ScheduleModal.tsx
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
/**
|
||||||
|
* Schedule Modal Component
|
||||||
|
*
|
||||||
|
* Modal for managing agent schedules (create, edit, delete).
|
||||||
|
* Follows neobrutalism design patterns from SettingsModal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { Clock, Trash2, X } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
useSchedules,
|
||||||
|
useCreateSchedule,
|
||||||
|
useDeleteSchedule,
|
||||||
|
useToggleSchedule,
|
||||||
|
} from '../hooks/useSchedules'
|
||||||
|
import {
|
||||||
|
utcToLocal,
|
||||||
|
localToUTC,
|
||||||
|
formatDuration,
|
||||||
|
DAYS,
|
||||||
|
isDayActive,
|
||||||
|
toggleDay,
|
||||||
|
} from '../lib/timeUtils'
|
||||||
|
import type { ScheduleCreate } from '../lib/types'
|
||||||
|
|
||||||
|
interface ScheduleModalProps {
|
||||||
|
projectName: string
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleModal({ projectName, isOpen, onClose }: ScheduleModalProps) {
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
const firstFocusableRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
// Queries and mutations
|
||||||
|
const { data: schedulesData, isLoading } = useSchedules(projectName)
|
||||||
|
const createSchedule = useCreateSchedule(projectName)
|
||||||
|
const deleteSchedule = useDeleteSchedule(projectName)
|
||||||
|
const toggleSchedule = useToggleSchedule(projectName)
|
||||||
|
|
||||||
|
// Form state for new schedule
|
||||||
|
const [newSchedule, setNewSchedule] = useState<ScheduleCreate>({
|
||||||
|
start_time: '22:00',
|
||||||
|
duration_minutes: 240,
|
||||||
|
days_of_week: 31, // Weekdays by default
|
||||||
|
enabled: true,
|
||||||
|
yolo_mode: false,
|
||||||
|
model: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Focus trap
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && firstFocusableRef.current) {
|
||||||
|
firstFocusableRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Tab' && modalRef.current) {
|
||||||
|
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
const firstElement = focusableElements[0]
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1]
|
||||||
|
|
||||||
|
if (e.shiftKey && document.activeElement === firstElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
lastElement?.focus()
|
||||||
|
} else if (!e.shiftKey && document.activeElement === lastElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
firstElement?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const schedules = schedulesData?.schedules || []
|
||||||
|
|
||||||
|
const handleCreateSchedule = async () => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (newSchedule.days_of_week === 0) {
|
||||||
|
setError('Please select at least one day')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert local time to UTC
|
||||||
|
const scheduleToCreate = {
|
||||||
|
...newSchedule,
|
||||||
|
start_time: localToUTC(newSchedule.start_time),
|
||||||
|
}
|
||||||
|
|
||||||
|
await createSchedule.mutateAsync(scheduleToCreate)
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setNewSchedule({
|
||||||
|
start_time: '22:00',
|
||||||
|
duration_minutes: 240,
|
||||||
|
days_of_week: 31,
|
||||||
|
enabled: true,
|
||||||
|
yolo_mode: false,
|
||||||
|
model: null,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create schedule')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleSchedule = async (scheduleId: number, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
await toggleSchedule.mutateAsync({ scheduleId, enabled: !enabled })
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to toggle schedule')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteSchedule = async (scheduleId: number) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this schedule?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
await deleteSchedule.mutateAsync(scheduleId)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to delete schedule')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleDay = (dayBit: number) => {
|
||||||
|
setNewSchedule((prev) => ({
|
||||||
|
...prev,
|
||||||
|
days_of_week: toggleDay(prev.days_of_week, dayBit),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="neo-modal-backdrop"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div ref={modalRef} className="neo-modal p-6" style={{ maxWidth: '650px', maxHeight: '80vh' }}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock size={24} className="text-[var(--color-neo-progress)]" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">Agent Schedules</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref={firstFocusableRef}
|
||||||
|
onClick={onClose}
|
||||||
|
className="neo-btn neo-btn-ghost p-2"
|
||||||
|
aria-label="Close modal"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error display */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 border-2 border-red-500 bg-red-50 text-red-800 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="text-center py-8 text-gray-600">
|
||||||
|
Loading schedules...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing schedules */}
|
||||||
|
{!isLoading && schedules.length > 0 && (
|
||||||
|
<div className="space-y-3 mb-6 max-h-[300px] overflow-y-auto">
|
||||||
|
{schedules.map((schedule) => {
|
||||||
|
const localTime = utcToLocal(schedule.start_time)
|
||||||
|
const duration = formatDuration(schedule.duration_minutes)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={schedule.id}
|
||||||
|
className="neo-card p-4 flex items-start justify-between gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Time and duration */}
|
||||||
|
<div className="flex items-baseline gap-2 mb-2">
|
||||||
|
<span className="text-lg font-bold text-gray-900">{localTime}</span>
|
||||||
|
<span className="text-sm text-gray-600">
|
||||||
|
for {duration}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days */}
|
||||||
|
<div className="flex gap-1 mb-2">
|
||||||
|
{DAYS.map((day) => {
|
||||||
|
const isActive = isDayActive(schedule.days_of_week, day.bit)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={day.label}
|
||||||
|
className={`text-xs px-2 py-1 rounded border-2 ${
|
||||||
|
isActive
|
||||||
|
? 'border-[var(--color-neo-progress)] bg-[var(--color-neo-progress)] text-white font-bold'
|
||||||
|
: 'border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="flex gap-3 text-xs text-gray-600">
|
||||||
|
{schedule.yolo_mode && (
|
||||||
|
<span className="font-bold text-yellow-600">⚡ YOLO mode</span>
|
||||||
|
)}
|
||||||
|
{schedule.model && <span>Model: {schedule.model}</span>}
|
||||||
|
{schedule.crash_count > 0 && (
|
||||||
|
<span className="text-red-600">Crashes: {schedule.crash_count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Enable/disable toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleSchedule(schedule.id, schedule.enabled)}
|
||||||
|
className={`neo-btn neo-btn-ghost px-3 py-1 text-xs font-bold ${
|
||||||
|
schedule.enabled
|
||||||
|
? 'text-[var(--color-neo-done)]'
|
||||||
|
: 'text-[var(--color-neo-text-secondary)]'
|
||||||
|
}`}
|
||||||
|
disabled={toggleSchedule.isPending}
|
||||||
|
>
|
||||||
|
{schedule.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSchedule(schedule.id)}
|
||||||
|
className="neo-btn neo-btn-ghost p-2 text-red-600 hover:bg-red-50"
|
||||||
|
disabled={deleteSchedule.isPending}
|
||||||
|
aria-label="Delete schedule"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && schedules.length === 0 && (
|
||||||
|
<div className="text-center py-6 text-gray-600 mb-6">
|
||||||
|
<Clock size={48} className="mx-auto mb-2 opacity-50 text-gray-400" />
|
||||||
|
<p>No schedules configured yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t-2 border-gray-200 my-6"></div>
|
||||||
|
|
||||||
|
{/* Add new schedule form */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-4">Add New Schedule</h3>
|
||||||
|
|
||||||
|
{/* Time and duration */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">Start Time (Local)</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={newSchedule.start_time}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSchedule((prev) => ({ ...prev, start_time: e.target.value }))
|
||||||
|
}
|
||||||
|
className="neo-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">Duration (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
value={newSchedule.duration_minutes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSchedule((prev) => ({
|
||||||
|
...prev,
|
||||||
|
duration_minutes: parseInt(e.target.value) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="neo-input w-full"
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-gray-600 mt-1">
|
||||||
|
{formatDuration(newSchedule.duration_minutes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days of week */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">Days</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{DAYS.map((day) => {
|
||||||
|
const isActive = isDayActive(newSchedule.days_of_week, day.bit)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.label}
|
||||||
|
onClick={() => handleToggleDay(day.bit)}
|
||||||
|
className={`neo-btn px-3 py-2 text-sm ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[var(--color-neo-progress)] text-white border-[var(--color-neo-progress)]'
|
||||||
|
: 'neo-btn-ghost'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* YOLO mode toggle */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newSchedule.yolo_mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSchedule((prev) => ({ ...prev, yolo_mode: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-bold text-gray-700">YOLO Mode (skip testing)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model selection (optional) */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-bold text-gray-700 mb-2">
|
||||||
|
Model (optional, defaults to global setting)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., claude-3-5-sonnet-20241022"
|
||||||
|
value={newSchedule.model || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewSchedule((prev) => ({ ...prev, model: e.target.value || null }))
|
||||||
|
}
|
||||||
|
className="neo-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button onClick={onClose} className="neo-btn neo-btn-ghost">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCreateSchedule}
|
||||||
|
disabled={createSchedule.isPending || newSchedule.days_of_week === 0}
|
||||||
|
className="neo-btn neo-btn-primary"
|
||||||
|
>
|
||||||
|
{createSchedule.isPending ? 'Creating...' : 'Create Schedule'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -359,7 +359,7 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) {
|
|||||||
// when the container is first rendered.
|
// when the container is first rendered.
|
||||||
|
|
||||||
// Handle keyboard input
|
// Handle keyboard input
|
||||||
terminal.onData((data) => {
|
terminal.onData((data: string) => {
|
||||||
// If shell has exited, reconnect on any key
|
// If shell has exited, reconnect on any key
|
||||||
// Use ref to avoid re-creating this callback when hasExited changes
|
// Use ref to avoid re-creating this callback when hasExited changes
|
||||||
if (hasExitedRef.current) {
|
if (hasExitedRef.current) {
|
||||||
@@ -378,7 +378,7 @@ export function Terminal({ projectName, terminalId, isActive }: TerminalProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Handle terminal resize
|
// Handle terminal resize
|
||||||
terminal.onResize(({ cols, rows }) => {
|
terminal.onResize(({ cols, rows }: { cols: number; rows: number }) => {
|
||||||
sendResize(cols, rows)
|
sendResize(cols, rows)
|
||||||
})
|
})
|
||||||
}, [encodeBase64, sendMessage, sendResize])
|
}, [encodeBase64, sendMessage, sendResize])
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export function useStopAgent(projectName: string) {
|
|||||||
mutationFn: () => api.stopAgent(projectName),
|
mutationFn: () => api.stopAgent(projectName),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||||
|
// Invalidate schedule status to reflect manual stop override
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
112
ui/src/hooks/useSchedules.ts
Normal file
112
ui/src/hooks/useSchedules.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* React Query hooks for schedule data
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import * as api from '../lib/api'
|
||||||
|
import type { ScheduleCreate, ScheduleUpdate } from '../lib/types'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schedules
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all schedules for a project.
|
||||||
|
*/
|
||||||
|
export function useSchedules(projectName: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['schedules', projectName],
|
||||||
|
queryFn: () => api.listSchedules(projectName!),
|
||||||
|
enabled: !!projectName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch a single schedule.
|
||||||
|
*/
|
||||||
|
export function useSchedule(projectName: string | null, scheduleId: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['schedule', projectName, scheduleId],
|
||||||
|
queryFn: () => api.getSchedule(projectName!, scheduleId!),
|
||||||
|
enabled: !!projectName && !!scheduleId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to create a new schedule.
|
||||||
|
*/
|
||||||
|
export function useCreateSchedule(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (schedule: ScheduleCreate) => api.createSchedule(projectName, schedule),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['schedules', projectName] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to update an existing schedule.
|
||||||
|
*/
|
||||||
|
export function useUpdateSchedule(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ scheduleId, update }: { scheduleId: number; update: ScheduleUpdate }) =>
|
||||||
|
api.updateSchedule(projectName, scheduleId, update),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['schedules', projectName] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to delete a schedule.
|
||||||
|
*/
|
||||||
|
export function useDeleteSchedule(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (scheduleId: number) => api.deleteSchedule(projectName, scheduleId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['schedules', projectName] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to toggle a schedule's enabled state.
|
||||||
|
*/
|
||||||
|
export function useToggleSchedule(projectName: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ scheduleId, enabled }: { scheduleId: number; enabled: boolean }) =>
|
||||||
|
api.updateSchedule(projectName, scheduleId, { enabled }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['schedules', projectName] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['nextRun', projectName] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Next Run
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch the next scheduled run for a project.
|
||||||
|
* Polls every 30 seconds to keep status up-to-date.
|
||||||
|
*/
|
||||||
|
export function useNextScheduledRun(projectName: string | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['nextRun', projectName],
|
||||||
|
queryFn: () => api.getNextScheduledRun(projectName!),
|
||||||
|
enabled: !!projectName,
|
||||||
|
refetchInterval: 30000, // Refresh every 30 seconds
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ import type {
|
|||||||
DevServerStatusResponse,
|
DevServerStatusResponse,
|
||||||
DevServerConfig,
|
DevServerConfig,
|
||||||
TerminalInfo,
|
TerminalInfo,
|
||||||
|
Schedule,
|
||||||
|
ScheduleCreate,
|
||||||
|
ScheduleUpdate,
|
||||||
|
ScheduleListResponse,
|
||||||
|
NextRunResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const API_BASE = '/api'
|
const API_BASE = '/api'
|
||||||
@@ -44,6 +49,11 @@ async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
|||||||
throw new Error(error.detail || `HTTP ${response.status}`)
|
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle 204 No Content responses
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,3 +451,52 @@ export async function deleteTerminal(
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schedule API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function listSchedules(projectName: string): Promise<ScheduleListResponse> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSchedule(
|
||||||
|
projectName: string,
|
||||||
|
schedule: ScheduleCreate
|
||||||
|
): Promise<Schedule> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(schedule),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSchedule(
|
||||||
|
projectName: string,
|
||||||
|
scheduleId: number
|
||||||
|
): Promise<Schedule> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSchedule(
|
||||||
|
projectName: string,
|
||||||
|
scheduleId: number,
|
||||||
|
update: ScheduleUpdate
|
||||||
|
): Promise<Schedule> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSchedule(
|
||||||
|
projectName: string,
|
||||||
|
scheduleId: number
|
||||||
|
): Promise<void> {
|
||||||
|
await fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/${scheduleId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNextScheduledRun(projectName: string): Promise<NextRunResponse> {
|
||||||
|
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/schedules/next`)
|
||||||
|
}
|
||||||
|
|||||||
155
ui/src/lib/timeUtils.ts
Normal file
155
ui/src/lib/timeUtils.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Time Zone Utilities
|
||||||
|
* ====================
|
||||||
|
*
|
||||||
|
* Utilities for converting between UTC and local time for schedule management.
|
||||||
|
* All times in the database are stored in UTC and displayed in the user's local timezone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert "HH:MM" UTC time to user's local time.
|
||||||
|
* @param utcTime Time string in "HH:MM" format (UTC)
|
||||||
|
* @returns Time string in "HH:MM" format (local)
|
||||||
|
*/
|
||||||
|
export function utcToLocal(utcTime: string): string {
|
||||||
|
const [hours, minutes] = utcTime.split(':').map(Number)
|
||||||
|
const utcDate = new Date()
|
||||||
|
utcDate.setUTCHours(hours, minutes, 0, 0)
|
||||||
|
|
||||||
|
const localHours = utcDate.getHours()
|
||||||
|
const localMinutes = utcDate.getMinutes()
|
||||||
|
|
||||||
|
return `${String(localHours).padStart(2, '0')}:${String(localMinutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert "HH:MM" local time to UTC for storage.
|
||||||
|
* @param localTime Time string in "HH:MM" format (local)
|
||||||
|
* @returns Time string in "HH:MM" format (UTC)
|
||||||
|
*/
|
||||||
|
export function localToUTC(localTime: string): string {
|
||||||
|
const [hours, minutes] = localTime.split(':').map(Number)
|
||||||
|
const localDate = new Date()
|
||||||
|
localDate.setHours(hours, minutes, 0, 0)
|
||||||
|
|
||||||
|
const utcHours = localDate.getUTCHours()
|
||||||
|
const utcMinutes = localDate.getUTCMinutes()
|
||||||
|
|
||||||
|
return `${String(utcHours).padStart(2, '0')}:${String(utcMinutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a duration in minutes to a human-readable string.
|
||||||
|
* @param minutes Duration in minutes
|
||||||
|
* @returns Formatted string (e.g., "4h", "1h 30m", "30m")
|
||||||
|
*/
|
||||||
|
export function formatDuration(minutes: number): string {
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const mins = minutes % 60
|
||||||
|
|
||||||
|
if (hours === 0) return `${mins}m`
|
||||||
|
if (mins === 0) return `${hours}h`
|
||||||
|
return `${hours}h ${mins}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO datetime string to a human-readable next run format.
|
||||||
|
* Uses the browser's locale settings for 12/24-hour format.
|
||||||
|
* @param isoString ISO datetime string in UTC
|
||||||
|
* @returns Formatted string (e.g., "22:00", "10:00 PM", "Mon 22:00")
|
||||||
|
*/
|
||||||
|
export function formatNextRun(isoString: string): string {
|
||||||
|
const date = new Date(isoString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = date.getTime() - now.getTime()
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
|
||||||
|
if (diffHours < 24) {
|
||||||
|
// Same day or within 24 hours - just show time
|
||||||
|
return date.toLocaleTimeString([], {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Further out - show day and time
|
||||||
|
return date.toLocaleDateString([], {
|
||||||
|
weekday: 'short',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO datetime string to show the end time.
|
||||||
|
* Uses the browser's locale settings for 12/24-hour format.
|
||||||
|
* @param isoString ISO datetime string in UTC
|
||||||
|
* @returns Formatted string (e.g., "14:00", "2:00 PM")
|
||||||
|
*/
|
||||||
|
export function formatEndTime(isoString: string): string {
|
||||||
|
const date = new Date(isoString)
|
||||||
|
return date.toLocaleTimeString([], {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day bit values for the days_of_week bitfield.
|
||||||
|
*/
|
||||||
|
export const DAY_BITS = {
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 4,
|
||||||
|
Thu: 8,
|
||||||
|
Fri: 16,
|
||||||
|
Sat: 32,
|
||||||
|
Sun: 64,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of days with their labels and bit values.
|
||||||
|
*/
|
||||||
|
export const DAYS = [
|
||||||
|
{ label: 'Mon', bit: 1 },
|
||||||
|
{ label: 'Tue', bit: 2 },
|
||||||
|
{ label: 'Wed', bit: 4 },
|
||||||
|
{ label: 'Thu', bit: 8 },
|
||||||
|
{ label: 'Fri', bit: 16 },
|
||||||
|
{ label: 'Sat', bit: 32 },
|
||||||
|
{ label: 'Sun', bit: 64 },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a day is active in a bitfield.
|
||||||
|
* @param bitfield The days_of_week bitfield
|
||||||
|
* @param dayBit The bit value for the day to check
|
||||||
|
* @returns True if the day is active
|
||||||
|
*/
|
||||||
|
export function isDayActive(bitfield: number, dayBit: number): boolean {
|
||||||
|
return (bitfield & dayBit) !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a day in a bitfield.
|
||||||
|
* @param bitfield The current days_of_week bitfield
|
||||||
|
* @param dayBit The bit value for the day to toggle
|
||||||
|
* @returns New bitfield with the day toggled
|
||||||
|
*/
|
||||||
|
export function toggleDay(bitfield: number, dayBit: number): number {
|
||||||
|
return bitfield ^ dayBit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable description of active days.
|
||||||
|
* @param bitfield The days_of_week bitfield
|
||||||
|
* @returns Description string (e.g., "Every day", "Weekdays", "Mon, Wed, Fri")
|
||||||
|
*/
|
||||||
|
export function formatDaysDescription(bitfield: number): string {
|
||||||
|
if (bitfield === 127) return 'Every day'
|
||||||
|
if (bitfield === 31) return 'Weekdays'
|
||||||
|
if (bitfield === 96) return 'Weekends'
|
||||||
|
|
||||||
|
const activeDays = DAYS.filter(d => isDayActive(bitfield, d.bit))
|
||||||
|
return activeDays.map(d => d.label).join(', ')
|
||||||
|
}
|
||||||
@@ -489,3 +489,50 @@ export interface SettingsUpdate {
|
|||||||
testing_agent_ratio?: number
|
testing_agent_ratio?: number
|
||||||
count_testing_in_concurrency?: boolean
|
count_testing_in_concurrency?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schedule Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Schedule {
|
||||||
|
id: number
|
||||||
|
project_name: string
|
||||||
|
start_time: string // "HH:MM" in UTC
|
||||||
|
duration_minutes: number
|
||||||
|
days_of_week: number // Bitfield: Mon=1, Tue=2, Wed=4, Thu=8, Fri=16, Sat=32, Sun=64
|
||||||
|
enabled: boolean
|
||||||
|
yolo_mode: boolean
|
||||||
|
model: string | null
|
||||||
|
crash_count: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleCreate {
|
||||||
|
start_time: string // "HH:MM" format (local time, will be stored as UTC)
|
||||||
|
duration_minutes: number
|
||||||
|
days_of_week: number
|
||||||
|
enabled: boolean
|
||||||
|
yolo_mode: boolean
|
||||||
|
model: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleUpdate {
|
||||||
|
start_time?: string
|
||||||
|
duration_minutes?: number
|
||||||
|
days_of_week?: number
|
||||||
|
enabled?: boolean
|
||||||
|
yolo_mode?: boolean
|
||||||
|
model?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScheduleListResponse {
|
||||||
|
schedules: Schedule[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NextRunResponse {
|
||||||
|
has_schedules: boolean
|
||||||
|
next_start: string | null // ISO datetime in UTC
|
||||||
|
next_end: string | null // ISO datetime in UTC (latest end if overlapping)
|
||||||
|
is_currently_running: boolean
|
||||||
|
active_schedule_count: number
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user