Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 127 additions & 2 deletions api/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
"""

import sys
from datetime import datetime
from pathlib import Path
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.orm import Session, sessionmaker
from sqlalchemy.orm import Session, relationship, sessionmaker
from sqlalchemy.types import JSON

Base = declarative_base()
Expand Down Expand Up @@ -59,6 +60,93 @@ def get_dependencies_safe(self) -> list[int]:
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
max_concurrency = Column(Integer, nullable=False, default=3) # 1-5 concurrent agents

# 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,
"max_concurrency": self.max_concurrency,
"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:
"""Return the path to the SQLite database for a project."""
return project_dir / "features.db"
Expand Down Expand Up @@ -164,6 +252,40 @@ def _is_network_path(path: Path) -> bool:
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()

# Add max_concurrency column if missing (for upgrades)
if "max_concurrency" not in columns:
with engine.connect() as conn:
conn.execute(
text("ALTER TABLE schedules ADD COLUMN max_concurrency INTEGER DEFAULT 3")
)
conn.commit()


def create_database(project_dir: Path) -> tuple:
"""
Create database and return engine + session maker.
Expand Down Expand Up @@ -196,6 +318,9 @@ def create_database(project_dir: Path) -> tuple:
_migrate_fix_null_boolean_fields(engine)
_migrate_add_dependencies_column(engine)

# Migrate to add schedules tables
_migrate_add_schedules_tables(engine)

SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
return engine, SessionLocal

Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ websockets>=13.0
python-multipart>=0.0.17
psutil>=6.0.0
aiofiles>=24.0.0
apscheduler>=3.10.0,<4.0.0
pywinpty>=2.0.0; sys_platform == "win32"

# Dev dependencies
Expand Down
13 changes: 12 additions & 1 deletion server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
features_router,
filesystem_router,
projects_router,
schedules_router,
settings_router,
spec_creation_router,
terminal_router,
Expand All @@ -41,6 +42,7 @@
)
from .services.expand_chat_session import cleanup_all_expand_sessions
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 .websocket import project_websocket

Expand All @@ -55,8 +57,16 @@ async def lifespan(app: FastAPI):
# Startup - clean up orphaned lock files from previous runs
cleanup_orphaned_locks()
cleanup_orphaned_devserver_locks()

# Start the scheduler service
scheduler = get_scheduler()
await scheduler.start()

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_assistant_sessions()
await cleanup_all_expand_sessions()
Expand Down Expand Up @@ -110,6 +120,7 @@ async def require_localhost(request: Request, call_next):
app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(schedules_router)
app.include_router(devserver_router)
app.include_router(spec_creation_router)
app.include_router(expand_project_router)
Expand Down
2 changes: 2 additions & 0 deletions server/routers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .schedules import router as schedules_router
from .settings import router as settings_router
from .spec_creation import router as spec_creation_router
from .terminal import router as terminal_router
Expand All @@ -20,6 +21,7 @@
"projects_router",
"features_router",
"agent_router",
"schedules_router",
"devserver_router",
"spec_creation_router",
"expand_project_router",
Expand Down
14 changes: 14 additions & 0 deletions server/routers/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ async def start_agent(
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(
success=success,
status=manager.status,
Expand All @@ -144,6 +151,13 @@ async def stop_agent(project_name: str):

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(
success=success,
status=manager.status,
Expand Down
Loading
Loading