Initial schedule service implementation
Cron-like daemon that wakes reason service on schedule: - FastAPI on port 8084 - CRUD API for managing schedules - Cron expression support via croniter - Calls POST /process on reason service - Tracks execution history per task - 30-second check interval Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
27d0dce910
4 changed files with 441 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
1
__init__.py
Normal file
1
__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Egregore Schedule Service - Cron-like task scheduler"""
|
||||
187
main.py
Normal file
187
main.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
#!/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)
|
||||
250
scheduler.py
Normal file
250
scheduler.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""
|
||||
Egregore Schedule Service - Scheduler engine
|
||||
|
||||
Handles cron parsing, task storage, and execution via reason service.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from croniter import croniter
|
||||
|
||||
|
||||
REASON_URL = os.getenv("REASON_URL", "http://127.0.0.1:8081")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
id: str
|
||||
name: str
|
||||
cron: str
|
||||
instruction: str
|
||||
enabled: bool
|
||||
created_at: str
|
||||
last_run: Optional[str]
|
||||
run_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExecutionRecord:
|
||||
timestamp: str
|
||||
success: bool
|
||||
duration_ms: int
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class Scheduler:
|
||||
"""Manages scheduled tasks and executes them via reason service"""
|
||||
|
||||
def __init__(self, data_dir: str):
|
||||
self.data_dir = data_dir
|
||||
self.tasks_file = os.path.join(data_dir, "tasks.json")
|
||||
self.history_dir = os.path.join(data_dir, "history")
|
||||
self._running = False
|
||||
self._tasks: dict[str, ScheduledTask] = {}
|
||||
|
||||
# Ensure directories exist
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
os.makedirs(self.history_dir, exist_ok=True)
|
||||
|
||||
# Load existing tasks
|
||||
self._load_tasks()
|
||||
|
||||
def _load_tasks(self):
|
||||
"""Load tasks from disk"""
|
||||
if os.path.exists(self.tasks_file):
|
||||
try:
|
||||
with open(self.tasks_file, "r") as f:
|
||||
data = json.load(f)
|
||||
for task_data in data:
|
||||
task = ScheduledTask(**task_data)
|
||||
self._tasks[task.id] = task
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
def _save_tasks(self):
|
||||
"""Save tasks to disk"""
|
||||
data = [asdict(task) for task in self._tasks.values()]
|
||||
with open(self.tasks_file, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
def _validate_cron(self, cron_expr: str) -> bool:
|
||||
"""Validate a cron expression"""
|
||||
try:
|
||||
croniter(cron_expr)
|
||||
return True
|
||||
except (ValueError, KeyError):
|
||||
return False
|
||||
|
||||
def list_tasks(self) -> list[ScheduledTask]:
|
||||
"""List all scheduled tasks"""
|
||||
return list(self._tasks.values())
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[ScheduledTask]:
|
||||
"""Get a task by ID"""
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def add_task(self, task: ScheduledTask) -> None:
|
||||
"""Add a new task"""
|
||||
if not self._validate_cron(task.cron):
|
||||
raise ValueError(f"Invalid cron expression: {task.cron}")
|
||||
self._tasks[task.id] = task
|
||||
self._save_tasks()
|
||||
|
||||
def update_task(self, task: ScheduledTask) -> None:
|
||||
"""Update an existing task"""
|
||||
if not self._validate_cron(task.cron):
|
||||
raise ValueError(f"Invalid cron expression: {task.cron}")
|
||||
self._tasks[task.id] = task
|
||||
self._save_tasks()
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete a task"""
|
||||
if task_id in self._tasks:
|
||||
del self._tasks[task_id]
|
||||
self._save_tasks()
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_next_run(self, task: ScheduledTask) -> Optional[datetime]:
|
||||
"""Get the next run time for a task"""
|
||||
try:
|
||||
cron = croniter(task.cron, datetime.utcnow())
|
||||
return cron.get_next(datetime)
|
||||
except (ValueError, KeyError):
|
||||
return None
|
||||
|
||||
def get_history(self, task_id: str, limit: int = 20) -> list[dict]:
|
||||
"""Get execution history for a task"""
|
||||
history_file = os.path.join(self.history_dir, f"{task_id}.json")
|
||||
if not os.path.exists(history_file):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(history_file, "r") as f:
|
||||
records = json.load(f)
|
||||
return records[-limit:]
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def _append_history(self, task_id: str, record: ExecutionRecord):
|
||||
"""Append an execution record to history"""
|
||||
history_file = os.path.join(self.history_dir, f"{task_id}.json")
|
||||
|
||||
records = []
|
||||
if os.path.exists(history_file):
|
||||
try:
|
||||
with open(history_file, "r") as f:
|
||||
records = json.load(f)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
records.append(asdict(record))
|
||||
|
||||
# Keep last 100 records
|
||||
records = records[-100:]
|
||||
|
||||
with open(history_file, "w") as f:
|
||||
json.dump(records, f, indent=2)
|
||||
|
||||
async def execute_task(self, task: ScheduledTask) -> dict:
|
||||
"""Execute a task by calling the reason service"""
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
# Build conversation history with the scheduled instruction
|
||||
history = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"[Scheduled Task: {task.name}]\n\n{task.instruction}"
|
||||
}
|
||||
]
|
||||
|
||||
result = {"success": False, "error": None, "response": None}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
response = await client.post(
|
||||
f"{REASON_URL}/process",
|
||||
json={
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"history": history,
|
||||
"max_iterations": 10
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
result["success"] = True
|
||||
result["response"] = data.get("blocks", [])
|
||||
|
||||
except httpx.TimeoutException:
|
||||
result["error"] = "Request to reason service timed out"
|
||||
except httpx.HTTPStatusError as e:
|
||||
result["error"] = f"Reason service error: {e.response.status_code}"
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
|
||||
# Calculate duration
|
||||
end_time = datetime.utcnow()
|
||||
duration_ms = int((end_time - start_time).total_seconds() * 1000)
|
||||
|
||||
# Record execution
|
||||
record = ExecutionRecord(
|
||||
timestamp=start_time.isoformat() + "Z",
|
||||
success=result["success"],
|
||||
duration_ms=duration_ms,
|
||||
error=result["error"]
|
||||
)
|
||||
self._append_history(task.id, record)
|
||||
|
||||
# Update task
|
||||
task.last_run = start_time.isoformat() + "Z"
|
||||
task.run_count += 1
|
||||
self._save_tasks()
|
||||
|
||||
return result
|
||||
|
||||
def stop(self):
|
||||
"""Stop the scheduler loop"""
|
||||
self._running = False
|
||||
|
||||
async def run(self):
|
||||
"""Main scheduler loop - checks for due tasks every minute"""
|
||||
self._running = True
|
||||
print(f"[schedule] Scheduler started with {len(self._tasks)} tasks")
|
||||
|
||||
while self._running:
|
||||
now = datetime.utcnow()
|
||||
|
||||
for task in self._tasks.values():
|
||||
if not task.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Check if task should run now
|
||||
cron = croniter(task.cron, now)
|
||||
prev_time = cron.get_prev(datetime)
|
||||
|
||||
# If last run was before the previous scheduled time, run now
|
||||
if task.last_run:
|
||||
last_run_dt = datetime.fromisoformat(task.last_run.rstrip("Z"))
|
||||
if last_run_dt < prev_time:
|
||||
# Check we're within 60 seconds of the scheduled time
|
||||
if (now - prev_time).total_seconds() < 60:
|
||||
print(f"[schedule] Executing task: {task.name}")
|
||||
await self.execute_task(task)
|
||||
else:
|
||||
# Never run - check if we're within 60 seconds of schedule
|
||||
if (now - prev_time).total_seconds() < 60:
|
||||
print(f"[schedule] Executing task (first run): {task.name}")
|
||||
await self.execute_task(task)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[schedule] Error checking task {task.id}: {e}")
|
||||
|
||||
# Sleep for 30 seconds before next check
|
||||
await asyncio.sleep(30)
|
||||
Loading…
Add table
Add a link
Reference in a new issue