188 lines
5 KiB
Python
188 lines
5 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Egregore Schedule Service - Cron-like task scheduler
|
||
|
|
|
||
|
|
Provides HTTP API for managing scheduled tasks that wake the reason service.
|
||
|
|
Runs on port 8084.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import asyncio
|
||
|
|
import os
|
||
|
|
import uuid
|
||
|
|
from contextlib import asynccontextmanager
|
||
|
|
from datetime import datetime
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
from fastapi import FastAPI, HTTPException
|
||
|
|
from pydantic import BaseModel
|
||
|
|
|
||
|
|
from scheduler import Scheduler, ScheduledTask
|
||
|
|
|
||
|
|
|
||
|
|
# Lifespan for startup/shutdown
|
||
|
|
@asynccontextmanager
|
||
|
|
async def lifespan(app: FastAPI):
|
||
|
|
# Start the scheduler background task
|
||
|
|
scheduler_task = asyncio.create_task(scheduler.run())
|
||
|
|
yield
|
||
|
|
# Stop the scheduler
|
||
|
|
scheduler.stop()
|
||
|
|
scheduler_task.cancel()
|
||
|
|
try:
|
||
|
|
await scheduler_task
|
||
|
|
except asyncio.CancelledError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
app = FastAPI(title="Egregore Schedule Service", docs_url="/docs", lifespan=lifespan)
|
||
|
|
|
||
|
|
# Initialize scheduler
|
||
|
|
DATA_DIR = "/home/admin/data/schedules"
|
||
|
|
scheduler = Scheduler(DATA_DIR)
|
||
|
|
|
||
|
|
|
||
|
|
# Request/Response models
|
||
|
|
class ScheduleCreate(BaseModel):
|
||
|
|
name: str
|
||
|
|
cron: str # Cron expression (e.g., "0 9 * * *" for 9am daily)
|
||
|
|
instruction: str # What to tell reason to do
|
||
|
|
enabled: bool = True
|
||
|
|
|
||
|
|
|
||
|
|
class ScheduleUpdate(BaseModel):
|
||
|
|
name: Optional[str] = None
|
||
|
|
cron: Optional[str] = None
|
||
|
|
instruction: Optional[str] = None
|
||
|
|
enabled: Optional[bool] = None
|
||
|
|
|
||
|
|
|
||
|
|
class ScheduleResponse(BaseModel):
|
||
|
|
id: str
|
||
|
|
name: str
|
||
|
|
cron: str
|
||
|
|
instruction: str
|
||
|
|
enabled: bool
|
||
|
|
created_at: str
|
||
|
|
last_run: Optional[str]
|
||
|
|
next_run: Optional[str]
|
||
|
|
run_count: int
|
||
|
|
|
||
|
|
|
||
|
|
# Endpoints
|
||
|
|
@app.get("/health")
|
||
|
|
async def health():
|
||
|
|
"""Health check endpoint"""
|
||
|
|
return {"status": "ok", "service": "schedule"}
|
||
|
|
|
||
|
|
|
||
|
|
@app.get("/schedules")
|
||
|
|
async def list_schedules():
|
||
|
|
"""List all scheduled tasks"""
|
||
|
|
tasks = scheduler.list_tasks()
|
||
|
|
return {"schedules": [task_to_response(t) for t in tasks]}
|
||
|
|
|
||
|
|
|
||
|
|
@app.post("/schedules")
|
||
|
|
async def create_schedule(req: ScheduleCreate):
|
||
|
|
"""Create a new scheduled task"""
|
||
|
|
task_id = str(uuid.uuid4())[:8]
|
||
|
|
task = ScheduledTask(
|
||
|
|
id=task_id,
|
||
|
|
name=req.name,
|
||
|
|
cron=req.cron,
|
||
|
|
instruction=req.instruction,
|
||
|
|
enabled=req.enabled,
|
||
|
|
created_at=datetime.utcnow().isoformat() + "Z",
|
||
|
|
last_run=None,
|
||
|
|
run_count=0
|
||
|
|
)
|
||
|
|
|
||
|
|
try:
|
||
|
|
scheduler.add_task(task)
|
||
|
|
return task_to_response(task)
|
||
|
|
except ValueError as e:
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
|
||
|
|
|
||
|
|
@app.get("/schedules/{task_id}")
|
||
|
|
async def get_schedule(task_id: str):
|
||
|
|
"""Get a specific scheduled task"""
|
||
|
|
task = scheduler.get_task(task_id)
|
||
|
|
if not task:
|
||
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||
|
|
return task_to_response(task)
|
||
|
|
|
||
|
|
|
||
|
|
@app.patch("/schedules/{task_id}")
|
||
|
|
async def update_schedule(task_id: str, req: ScheduleUpdate):
|
||
|
|
"""Update a scheduled task"""
|
||
|
|
task = scheduler.get_task(task_id)
|
||
|
|
if not task:
|
||
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||
|
|
|
||
|
|
if req.name is not None:
|
||
|
|
task.name = req.name
|
||
|
|
if req.cron is not None:
|
||
|
|
task.cron = req.cron
|
||
|
|
if req.instruction is not None:
|
||
|
|
task.instruction = req.instruction
|
||
|
|
if req.enabled is not None:
|
||
|
|
task.enabled = req.enabled
|
||
|
|
|
||
|
|
try:
|
||
|
|
scheduler.update_task(task)
|
||
|
|
return task_to_response(task)
|
||
|
|
except ValueError as e:
|
||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
||
|
|
|
||
|
|
|
||
|
|
@app.delete("/schedules/{task_id}")
|
||
|
|
async def delete_schedule(task_id: str):
|
||
|
|
"""Delete a scheduled task"""
|
||
|
|
if not scheduler.delete_task(task_id):
|
||
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||
|
|
return {"status": "deleted", "id": task_id}
|
||
|
|
|
||
|
|
|
||
|
|
@app.post("/schedules/{task_id}/run")
|
||
|
|
async def run_schedule(task_id: str):
|
||
|
|
"""Manually trigger a scheduled task"""
|
||
|
|
task = scheduler.get_task(task_id)
|
||
|
|
if not task:
|
||
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||
|
|
|
||
|
|
result = await scheduler.execute_task(task)
|
||
|
|
return {"status": "executed", "id": task_id, "result": result}
|
||
|
|
|
||
|
|
|
||
|
|
@app.get("/schedules/{task_id}/history")
|
||
|
|
async def get_schedule_history(task_id: str, limit: int = 20):
|
||
|
|
"""Get execution history for a scheduled task"""
|
||
|
|
task = scheduler.get_task(task_id)
|
||
|
|
if not task:
|
||
|
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||
|
|
|
||
|
|
history = scheduler.get_history(task_id, limit)
|
||
|
|
return {"id": task_id, "history": history}
|
||
|
|
|
||
|
|
|
||
|
|
def task_to_response(task: ScheduledTask) -> dict:
|
||
|
|
"""Convert a task to API response format"""
|
||
|
|
next_run = scheduler.get_next_run(task) if task.enabled else None
|
||
|
|
return {
|
||
|
|
"id": task.id,
|
||
|
|
"name": task.name,
|
||
|
|
"cron": task.cron,
|
||
|
|
"instruction": task.instruction,
|
||
|
|
"enabled": task.enabled,
|
||
|
|
"created_at": task.created_at,
|
||
|
|
"last_run": task.last_run,
|
||
|
|
"next_run": next_run.isoformat() + "Z" if next_run else None,
|
||
|
|
"run_count": task.run_count
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
import uvicorn
|
||
|
|
uvicorn.run(app, host="127.0.0.1", port=8084)
|