feat: initial TaskPulse release

AI Agent 定时任务跟踪、监控、状态看板系统。

- FastAPI + Vue3 单体应用
- AI Agent 一键接入(批量注册 API)
- 暗色主题 Dashboard
- 后台调度器 + 超时告警
- 飞书/邮件/Webhook 多渠道通知
- Base URL 可配置
- Gunicorn 生产部署
This commit is contained in:
2026-06-14 22:39:23 +08:00
commit 521cf0269c
54 changed files with 6328 additions and 0 deletions
View File
+25
View File
@@ -0,0 +1,25 @@
"""API __init__ + shared dependencies."""
from fastapi import Header, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Agent
from app.services import AgentService
async def verify_api_key(
authorization: str = Header("", alias="Authorization"),
db: AsyncSession = None, # injected via Depends in router
) -> Agent:
"""Dependency: verify API Key in Authorization header."""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
api_key = authorization[7:]
svc = AgentService(db)
agent = await svc.get_by_api_key(api_key)
if agent is None:
raise HTTPException(status_code=401, detail="Invalid API Key")
if agent.status != "active":
raise HTTPException(status_code=403, detail="Agent is inactive")
return agent
+87
View File
@@ -0,0 +1,87 @@
"""API router — Agent management."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate
from app.services import AgentService, TaskService
router = APIRouter(prefix="/api/agents", tags=["agents"])
@router.post("", response_model=AgentOut, status_code=201)
async def register_agent(body: AgentCreate, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.create(name=body.name, description=body.description)
task_count = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": task_count})
@router.get("", response_model=list[AgentOut])
async def list_agents(db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agents = await svc.list_all()
result = []
for a in agents:
cnt = await svc.get_task_count(a.id)
result.append(AgentOut(**{**a.__dict__, "task_count": cnt}))
return result
@router.get("/{agent_id}", response_model=AgentOut)
async def get_agent(agent_id: int, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.get(agent_id)
if not agent:
raise HTTPException(404, "Agent not found")
cnt = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": cnt})
@router.put("/{agent_id}", response_model=AgentOut)
async def update_agent(agent_id: int, body: AgentUpdate, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.update(agent_id, **body.model_dump(exclude_none=True))
if not agent:
raise HTTPException(404, "Agent not found")
cnt = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": cnt})
@router.post("/{agent_id}/heartbeat", response_model=AgentOut)
async def heartbeat(agent_id: int, body: AgentHeartbeat, db: AsyncSession = Depends(get_db)):
svc = AgentService(db)
agent = await svc.heartbeat(agent_id)
if not agent:
raise HTTPException(404, "Agent not found")
cnt = await svc.get_task_count(agent.id)
return AgentOut(**{**agent.__dict__, "task_count": cnt})
# ── Batch registration: AI agent self-registers with all its tasks ─────
@router.post("/register-with-tasks", response_model=AgentBatchRegisterResult, status_code=201)
async def register_agent_with_tasks(body: AgentBatchRegister, db: AsyncSession = Depends(get_db)):
"""AI Agent 一次性注册自己 + 所有定时任务。"""
agent_svc = AgentService(db)
task_svc = TaskService(db)
agent = await agent_svc.create(name=body.name, description=body.description)
tasks_created = 0
for t in body.tasks:
await task_svc.create(
agent_id=agent.id,
name=t.name,
cron_expression=t.cron_expression,
description=t.description,
grace_period=t.grace_period,
)
tasks_created += 1
await db.commit()
task_count = await agent_svc.get_task_count(agent.id)
return AgentBatchRegisterResult(
agent=AgentOut(**{**agent.__dict__, "task_count": task_count}),
tasks_created=tasks_created,
)
+115
View File
@@ -0,0 +1,115 @@
"""API router — Dashboard overview."""
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Agent, Alert, ScheduledTask, TaskExecution
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
class DashboardSummary(BaseModel):
total_agents: int
active_agents: int
total_tasks: int
active_tasks: int
total_executions: int
recent_executions_ok: int
recent_executions_failed: int
pending_alerts: int
class DashboardTaskItem(BaseModel):
id: int
agent_name: str
name: str
cron_expression: str
status: str
last_run_at: datetime | None = None
last_run_result: str | None = None
next_run_at: datetime | None = None
total_run_count: int = 0
@router.get("/summary", response_model=DashboardSummary)
async def dashboard_summary(db: AsyncSession = Depends(get_db)):
# Agents
total_agents = (await db.execute(select(func.count(Agent.id)))).scalar() or 0
active_agents = (
await db.execute(select(func.count(Agent.id)).where(Agent.status == "active"))
).scalar() or 0
# Tasks
total_tasks = (await db.execute(select(func.count(ScheduledTask.id)))).scalar() or 0
active_tasks = (
await db.execute(
select(func.count(ScheduledTask.id)).where(ScheduledTask.status == "active")
)
).scalar() or 0
# Executions (last 24h)
since = datetime.utcnow() - timedelta(hours=24)
total_execs = (
await db.execute(
select(func.count(TaskExecution.id)).where(TaskExecution.created_at >= since)
)
).scalar() or 0
ok_execs = (
await db.execute(
select(func.count(TaskExecution.id)).where(
TaskExecution.created_at >= since, TaskExecution.status == "success"
)
)
).scalar() or 0
failed_execs = (
await db.execute(
select(func.count(TaskExecution.id)).where(
TaskExecution.created_at >= since, TaskExecution.status == "failed"
)
)
).scalar() or 0
# Alerts
pending_alerts = (
await db.execute(
select(func.count(Alert.id)).where(Alert.status == "pending")
)
).scalar() or 0
return DashboardSummary(
total_agents=total_agents,
active_agents=active_agents,
total_tasks=total_tasks,
active_tasks=active_tasks,
total_executions=total_execs,
recent_executions_ok=ok_execs,
recent_executions_failed=failed_execs,
pending_alerts=pending_alerts,
)
@router.get("/tasks", response_model=list[DashboardTaskItem])
async def dashboard_tasks(db: AsyncSession = Depends(get_db)):
"""Return all tasks with agent name for the unified view."""
stmt = select(ScheduledTask).order_by(ScheduledTask.id)
tasks = (await db.execute(stmt)).scalars().all()
result = []
for t in tasks:
agent = await db.get(Agent, t.agent_id)
result.append(DashboardTaskItem(
id=t.id,
agent_name=agent.name if agent else "",
name=t.name,
cron_expression=t.cron_expression,
status=t.status,
last_run_at=t.last_run_at,
last_run_result=t.last_run_result,
next_run_at=t.next_run_at,
total_run_count=t.total_run_count or 0,
))
return result
+70
View File
@@ -0,0 +1,70 @@
"""API router — TaskExecution (reporting + query)."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import ExecutionOut, ExecutionReport
from app.services import ExecutionService, TaskService, AgentService
router = APIRouter(prefix="/api/tasks/{task_id}/executions", tags=["executions"])
@router.post("", response_model=ExecutionOut, status_code=201)
async def report_execution(task_id: int, body: ExecutionReport,
db: AsyncSession = Depends(get_db)):
"""Agent reports execution result for a task."""
# Verify task exists
task_svc = TaskService(db)
task = await task_svc.get(task_id)
if not task:
raise HTTPException(404, "Task not found")
exec_svc = ExecutionService(db)
record = await exec_svc.report_result(
task_id=task_id,
agent_id=task.agent_id,
status=body.status,
finished_at=body.finished_at,
duration_ms=body.duration_ms,
result=body.result,
log=body.log,
error_message=body.error_message,
)
# Update task's last_run info
await task_svc.mark_run(task_id, success=(body.status == "success"))
await db.commit()
return ExecutionOut(**record.__dict__)
@router.get("", response_model=list[ExecutionOut])
async def list_executions(
task_id: int,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
exec_svc = ExecutionService(db)
records = await exec_svc.get_executions(task_id, limit=limit, offset=offset)
return [ExecutionOut(**r.__dict__) for r in records]
@router.get("/recent", response_model=list[ExecutionOut])
async def recent_executions(
limit: int = Query(100, ge=1, le=500),
db: AsyncSession = Depends(get_db),
):
"""Get recent executions across all tasks (for dashboard)."""
exec_svc = ExecutionService(db)
records = await exec_svc.get_recent_executions(limit=limit)
return [ExecutionOut(**r.__dict__) for r in records]
@router.get("/{execution_id}", response_model=ExecutionOut)
async def get_execution(execution_id: int, db: AsyncSession = Depends(get_db)):
exec_svc = ExecutionService(db)
record = await exec_svc.get_execution(execution_id)
if not record:
raise HTTPException(404, "Execution not found")
return ExecutionOut(**record.__dict__)
+158
View File
@@ -0,0 +1,158 @@
"""API router — Notifications & Alerts."""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import (
AlertAcknowledge,
AlertOut,
AlertRuleCreate,
AlertRuleOut,
NotificationChannelCreate,
NotificationChannelOut,
NotificationChannelUpdate,
)
from app.models import Alert, AlertRule, NotificationChannel, ScheduledTask
router = APIRouter(prefix="/api", tags=["notifications"])
# ── Notification Channels ─────────────────────────────────────────────
@router.post("/notification-channels", response_model=NotificationChannelOut, status_code=201)
async def create_channel(body: NotificationChannelCreate, db: AsyncSession = Depends(get_db)):
channel = NotificationChannel(
name=body.name,
channel_type=body.channel_type,
config=body.config,
)
db.add(channel)
await db.flush()
await db.refresh(channel)
await db.commit()
return NotificationChannelOut(**channel.__dict__)
@router.get("/notification-channels", response_model=list[NotificationChannelOut])
async def list_channels(db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
result = await db.execute(select(NotificationChannel).order_by(NotificationChannel.id))
channels = result.scalars().all()
return [NotificationChannelOut(**c.__dict__) for c in channels]
@router.put("/notification-channels/{channel_id}", response_model=NotificationChannelOut)
async def update_channel(channel_id: int, body: NotificationChannelUpdate,
db: AsyncSession = Depends(get_db)):
channel = await db.get(NotificationChannel, channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
for k, v in body.model_dump(exclude_none=True).items():
setattr(channel, k, v)
await db.flush()
await db.commit()
return NotificationChannelOut(**channel.__dict__)
@router.delete("/notification-channels/{channel_id}", status_code=204)
async def delete_channel(channel_id: int, db: AsyncSession = Depends(get_db)):
channel = await db.get(NotificationChannel, channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
await db.delete(channel)
await db.flush()
await db.commit()
# ── Alert Rules ───────────────────────────────────────────────────────
@router.post("/alert-rules", response_model=AlertRuleOut, status_code=201)
async def create_alert_rule(body: AlertRuleCreate, db: AsyncSession = Depends(get_db)):
# Verify task & channel exist
task = await db.get(ScheduledTask, body.task_id)
if not task:
raise HTTPException(404, "Task not found")
channel = await db.get(NotificationChannel, body.channel_id)
if not channel:
raise HTTPException(404, "Channel not found")
rule = AlertRule(
task_id=body.task_id,
channel_id=body.channel_id,
alert_type=body.alert_type,
)
db.add(rule)
await db.flush()
await db.refresh(rule)
await db.commit()
return AlertRuleOut(**{**rule.__dict__, "channel_name": channel.name})
@router.get("/alert-rules", response_model=list[AlertRuleOut])
async def list_alert_rules(task_id: int | None = Query(None), db: AsyncSession = Depends(get_db)):
from sqlalchemy import select
stmt = select(AlertRule)
if task_id:
stmt = stmt.where(AlertRule.task_id == task_id)
result = await db.execute(stmt)
rules = result.scalars().all()
output = []
for r in rules:
ch = await db.get(NotificationChannel, r.channel_id)
output.append(AlertRuleOut(**{**r.__dict__, "channel_name": ch.name if ch else ""}))
return output
@router.delete("/alert-rules/{rule_id}", status_code=204)
async def delete_alert_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
rule = await db.get(AlertRule, rule_id)
if not rule:
raise HTTPException(404, "Alert rule not found")
await db.delete(rule)
await db.flush()
await db.commit()
# ── Alerts ────────────────────────────────────────────────────────────
@router.get("/alerts", response_model=list[AlertOut])
async def list_alerts(
status: str | None = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
from sqlalchemy import select, desc
stmt = select(Alert).order_by(desc(Alert.id)).offset(offset).limit(limit)
if status:
stmt = stmt.where(Alert.status == status)
result = await db.execute(stmt)
alerts = result.scalars().all()
output = []
for a in alerts:
task_name = ""
if a.task_id:
task = await db.get(ScheduledTask, a.task_id)
task_name = task.name if task else ""
output.append(AlertOut(**{**a.__dict__, "task_name": task_name}))
return output
@router.post("/alerts/{alert_id}/acknowledge", response_model=AlertOut)
async def acknowledge_alert(alert_id: int, body: AlertAcknowledge,
db: AsyncSession = Depends(get_db)):
from datetime import datetime
alert = await db.get(Alert, alert_id)
if not alert:
raise HTTPException(404, "Alert not found")
alert.status = "acknowledged"
alert.acknowledged_at = datetime.utcnow()
await db.flush()
await db.commit()
task_name = ""
if alert.task_id:
task = await db.get(ScheduledTask, alert.task_id)
task_name = task.name if task else ""
return AlertOut(**{**alert.__dict__, "task_name": task_name})
+77
View File
@@ -0,0 +1,77 @@
"""API router — System configuration (base_url, etc.)."""
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.system_config import SystemConfig
router = APIRouter(prefix="/api/system", tags=["system"])
class SystemConfigOut(BaseModel):
base_url: str = ""
scheduler_check_interval: int = 60
class SystemConfigUpdate(BaseModel):
base_url: str = ""
@router.get("/config", response_model=SystemConfigOut)
async def get_system_config(request: Request, db: AsyncSession = Depends(get_db)):
"""返回系统配置。base_url 默认从请求 host 推断,也可从数据库读取用户配置。"""
# Try to get from DB first
stmt = select(SystemConfig).where(SystemConfig.key == "base_url")
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if row and row.value:
base_url = row.value
else:
# Infer from request
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.hostname)
base_url = f"{scheme}://{host}"
return SystemConfigOut(
base_url=base_url,
scheduler_check_interval=60,
)
@router.put("/config", response_model=SystemConfigOut)
async def update_system_config(body: SystemConfigUpdate, request: Request,
db: AsyncSession = Depends(get_db)):
"""更新系统配置并持久化到数据库。"""
# Upsert base_url
stmt = select(SystemConfig).where(SystemConfig.key == "base_url")
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if body.base_url:
if row:
row.value = body.base_url
else:
row = SystemConfig(key="base_url", value=body.base_url,
description="系统公网访问地址")
db.add(row)
else:
# If clearing, remove from DB so we fall back to request host
if row:
await db.delete(row)
await db.flush()
await db.commit()
# Return current effective config
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("host", request.url.hostname)
effective_base = body.base_url or f"{scheme}://{host}"
return SystemConfigOut(
base_url=effective_base,
scheduler_check_interval=60,
)
+74
View File
@@ -0,0 +1,74 @@
"""API router — Task management."""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import TaskCreate, TaskOut, TaskUpdate
from app.services import TaskService, AgentService
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@router.post("", response_model=TaskOut, status_code=201)
async def create_task(body: TaskCreate, agent_id: int, db: AsyncSession = Depends(get_db)):
"""Create a task for a specific agent. Pass agent_id as query param."""
svc = TaskService(db)
agent_svc = AgentService(db)
agent = await agent_svc.get(agent_id)
if not agent:
raise HTTPException(404, "Agent not found")
task = await svc.create(
agent_id=agent_id,
name=body.name,
cron_expression=body.cron_expression,
description=body.description,
grace_period=body.grace_period,
)
return TaskOut(**{**task.__dict__, "agent_name": agent.name})
@router.get("", response_model=list[TaskOut])
async def list_tasks(agent_id: int | None = None, db: AsyncSession = Depends(get_db)):
"""List all tasks, optionally filtered by agent_id."""
svc = TaskService(db)
agent_svc = AgentService(db)
if agent_id:
tasks = await svc.list_by_agent(agent_id)
else:
tasks = await svc.list_all()
result = []
for t in tasks:
agent = await agent_svc.get(t.agent_id)
result.append(TaskOut(**{**t.__dict__, "agent_name": agent.name if agent else ""}))
return result
@router.get("/{task_id}", response_model=TaskOut)
async def get_task(task_id: int, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
agent_svc = AgentService(db)
task = await svc.get(task_id)
if not task:
raise HTTPException(404, "Task not found")
agent = await agent_svc.get(task.agent_id)
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
@router.put("/{task_id}", response_model=TaskOut)
async def update_task(task_id: int, body: TaskUpdate, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
agent_svc = AgentService(db)
task = await svc.update(task_id, **body.model_dump(exclude_none=True))
if not task:
raise HTTPException(404, "Task not found")
agent = await agent_svc.get(task.agent_id)
return TaskOut(**{**task.__dict__, "agent_name": agent.name if agent else ""})
@router.delete("/{task_id}", status_code=204)
async def delete_task(task_id: int, db: AsyncSession = Depends(get_db)):
svc = TaskService(db)
ok = await svc.delete(task_id)
if not ok:
raise HTTPException(404, "Task not found")
+49
View File
@@ -0,0 +1,49 @@
"""Application configuration via environment variables."""
import urllib.parse
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
# Database (internal network)
DB_HOST: str = "192.168.8.160"
DB_PORT: int = 3306
DB_USER: str = "dbuser"
DB_PASSWORD: str = "Tata@1234"
DB_NAME: str = "taskpulse"
# App
APP_HOST: str = "0.0.0.0"
APP_PORT: int = 8000
DEBUG: bool = True
# Secret
SECRET_KEY: str = "change-me-in-production"
# Scheduler
SCHEDULER_CHECK_INTERVAL: int = 60 # seconds
TASK_GRACE_PERIOD: int = 300 # seconds — how long after expected start before alerting
# Notifications
FEISHU_WEBHOOK_URL: str = ""
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = "taskpulse@example.com"
@property
def DATABASE_URL(self) -> str:
pwd = urllib.parse.quote_plus(self.DB_PASSWORD)
return f"mysql+aiomysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
@property
def DATABASE_URL_SYNC(self) -> str:
pwd = urllib.parse.quote_plus(self.DB_PASSWORD)
return f"mysql+pymysql://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
model_config = {"env_prefix": "TASKPULSE_", "env_file": ".env"}
settings = Settings()
+24
View File
@@ -0,0 +1,24 @@
"""Database engine and session management."""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
"""FastAPI dependency that yields a database session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
+80
View File
@@ -0,0 +1,80 @@
"""FastAPI application entry point."""
import asyncio
import logging
from pathlib import Path
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.api.agents import router as agents_router
from app.api.tasks import router as tasks_router
from app.api.executions import router as executions_router
from app.api.notifications import router as notifications_router
from app.api.dashboard import router as dashboard_router
from app.api.system import router as system_router
from app.config import settings
from app.database import engine, Base
from app.services.scheduler import SchedulerService
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger("taskpulse")
scheduler = SchedulerService()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: create tables + start background scheduler."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database tables ensured")
# Start background scheduler
task = asyncio.create_task(scheduler.start())
yield
# Shutdown
await scheduler.stop()
task.cancel()
await engine.dispose()
app = FastAPI(
title="TaskPulse — AI Agent Task Monitor",
description="Unified dashboard for tracking, monitoring and alerting on AI agent scheduled tasks.",
version="1.0.0",
lifespan=lifespan,
)
# API routers
app.include_router(agents_router)
app.include_router(tasks_router)
app.include_router(executions_router)
app.include_router(notifications_router)
app.include_router(dashboard_router)
app.include_router(system_router)
# Serve Vue3 static files (SPA — monolithic deployment)
FRONTEND_DIST = Path(__file__).resolve().parent.parent.parent / "frontend" / "dist"
if FRONTEND_DIST.is_dir():
# Mount assets directory for JS/CSS/fonts
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
@app.exception_handler(404)
async def spa_fallback(request: Request, exc):
"""Return index.html for any non-API route (Vue Router SPA)."""
if not request.url.path.startswith("/api"):
content = (FRONTEND_DIST / "index.html").read_text(encoding="utf-8")
return HTMLResponse(content=content, status_code=200)
raise exc
logger.info("Frontend SPA mounted from %s", FRONTEND_DIST)
else:
logger.info("Frontend dist not found at %s — API-only mode", FRONTEND_DIST)
@app.get("/api/health")
async def health():
return {"status": "ok", "service": "taskpulse"}
+9
View File
@@ -0,0 +1,9 @@
"""Model registry — all models are imported here so Alembic can discover them."""
from app.models.agent import Agent # noqa: F401
from app.models.task import ScheduledTask # noqa: F401
from app.models.execution import TaskExecution # noqa: F401
from app.models.notification import Alert, AlertRule, NotificationChannel # noqa: F401
from app.models.system_config import SystemConfig # noqa: F401
__all__ = ["Agent", "ScheduledTask", "TaskExecution", "NotificationChannel", "AlertRule", "Alert", "SystemConfig"]
+31
View File
@@ -0,0 +1,31 @@
"""SQLAlchemy models — Agent."""
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
def _gen_api_key() -> str:
return f"tp_{uuid.uuid4().hex}"
class Agent(Base):
__tablename__ = "agents"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, comment="Agent 名称")
description: Mapped[str] = mapped_column(Text, default="", comment="描述")
api_key: Mapped[str] = mapped_column(String(128), unique=True, default=_gen_api_key, comment="API Key")
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / inactive")
last_heartbeat_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最后心跳时间")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
tasks = relationship("ScheduledTask", back_populates="agent")
def __repr__(self) -> str:
return f"<Agent {self.name}>"
+30
View File
@@ -0,0 +1,30 @@
"""SQLAlchemy models — TaskExecution."""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class TaskExecution(Base):
__tablename__ = "task_executions"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
status: Mapped[str] = mapped_column(String(32), default="running", comment="running / success / failed")
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), comment="开始时间")
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="结束时间")
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="耗时(毫秒)")
result: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行结果摘要 (JSON)")
log: Mapped[str | None] = mapped_column(Text, nullable=True, comment="执行日志")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
task = relationship("ScheduledTask", back_populates="executions")
agent = relationship("Agent")
def __repr__(self) -> str:
return f"<TaskExecution #{self.id} task={self.task_id} status={self.status}>"
+60
View File
@@ -0,0 +1,60 @@
"""SQLAlchemy models — NotificationChannel & AlertRule & AlertHistory."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class NotificationChannel(Base):
"""用户绑定的通知渠道"""
__tablename__ = "notification_channels"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="渠道名称, e.g. 我的飞书")
channel_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="feishu_cli / email / webhook")
config: Mapped[str] = mapped_column(Text, nullable=False, comment="JSON 配置")
enabled: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
alert_rules = relationship("AlertRule", back_populates="channel", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<NotificationChannel {self.name} ({self.channel_type})>"
class AlertRule(Base):
"""告警规则:哪些任务触发哪些通知渠道"""
__tablename__ = "alert_rules"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
channel_id: Mapped[int] = mapped_column(ForeignKey("notification_channels.id", ondelete="CASCADE"), nullable=False)
alert_type: Mapped[str] = mapped_column(String(32), default="missed_run", comment="missed_run / failure")
enabled: Mapped[bool] = mapped_column(default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
task = relationship("ScheduledTask", back_populates="alert_rules")
channel = relationship("NotificationChannel", back_populates="alert_rules")
def __repr__(self) -> str:
return f"<AlertRule task={self.task_id} → channel={self.channel_id}>"
class Alert(Base):
"""告警历史"""
__tablename__ = "alerts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
task_id: Mapped[int] = mapped_column(ForeignKey("scheduled_tasks.id", ondelete="SET NULL"), nullable=True)
alert_type: Mapped[str] = mapped_column(String(32), nullable=False, comment="missed_run / failure / timeout")
message: Mapped[str] = mapped_column(Text, nullable=False, comment="告警内容")
status: Mapped[str] = mapped_column(String(32), default="pending", comment="pending / acknowledged / resolved")
acknowledged_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="确认时间")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
def __repr__(self) -> str:
return f"<Alert #{self.id} type={self.alert_type} status={self.status}>"
+21
View File
@@ -0,0 +1,21 @@
"""SQLAlchemy models — SystemConfig (key-value store for system settings)."""
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class SystemConfig(Base):
"""System-level configuration stored as key-value pairs."""
__tablename__ = "system_config"
key: Mapped[str] = mapped_column(String(128), primary_key=True, comment="配置键名")
value: Mapped[str] = mapped_column(Text, nullable=False, comment="配置值 (JSON)")
description: Mapped[str] = mapped_column(String(256), default="", comment="描述")
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
def __repr__(self) -> str:
return f"<SystemConfig {self.key}={self.value}>"
+36
View File
@@ -0,0 +1,36 @@
"""SQLAlchemy models — ScheduledTask."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ScheduledTask(Base):
__tablename__ = "scheduled_tasks"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(256), nullable=False, comment="任务名称")
description: Mapped[str] = mapped_column(Text, default="", comment="任务描述")
cron_expression: Mapped[str] = mapped_column(String(64), nullable=False, comment="Cron 表达式, e.g. */5 * * * *")
grace_period: Mapped[int] = mapped_column(Integer, default=300, comment="容忍秒数, 超时未执行则告警")
status: Mapped[str] = mapped_column(String(32), default="active", comment="active / paused / stopped")
# 冗余字段方便查询
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="最近一次执行时间")
last_run_result: Mapped[str | None] = mapped_column(String(32), nullable=True, comment="最近一次执行结果 success/failed")
next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="预计下次执行时间")
total_run_count: Mapped[int] = mapped_column(Integer, default=0, comment="累计执行次数")
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now())
agent = relationship("Agent", back_populates="tasks")
executions = relationship("TaskExecution", back_populates="task", cascade="all, delete-orphan")
alert_rules = relationship("AlertRule", back_populates="task", cascade="all, delete-orphan")
def __repr__(self) -> str:
return f"<ScheduledTask {self.name}>"
+23
View File
@@ -0,0 +1,23 @@
"""Pydantic schema registry."""
from app.schemas.agent import AgentBatchRegister, AgentBatchRegisterResult, AgentCreate, AgentHeartbeat, AgentOut, AgentUpdate # noqa: F401
from app.schemas.execution import ExecutionOut, ExecutionReport # noqa: F401
from app.schemas.notification import ( # noqa: F401
AlertAcknowledge,
AlertOut,
AlertRuleCreate,
AlertRuleOut,
NotificationChannelCreate,
NotificationChannelOut,
NotificationChannelUpdate,
)
from app.schemas.task import TaskCreate, TaskOut, TaskUpdate # noqa: F401
__all__ = [
"AgentCreate", "AgentUpdate", "AgentOut", "AgentHeartbeat",
"TaskCreate", "TaskUpdate", "TaskOut",
"ExecutionReport", "ExecutionOut",
"NotificationChannelCreate", "NotificationChannelUpdate", "NotificationChannelOut",
"AlertRuleCreate", "AlertRuleOut",
"AlertOut", "AlertAcknowledge",
]
+57
View File
@@ -0,0 +1,57 @@
"""Pydantic schemas — Agent."""
from datetime import datetime
from pydantic import BaseModel, Field
class AgentCreate(BaseModel):
name: str = Field(..., max_length=128)
description: str = Field(default="")
class AgentUpdate(BaseModel):
name: str | None = Field(None, max_length=128)
description: str | None = None
status: str | None = None # active / inactive
class AgentOut(BaseModel):
id: int
name: str
description: str
api_key: str
status: str
last_heartbeat_at: datetime | None = None
task_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class AgentHeartbeat(BaseModel):
pass
# ── Batch registration (AI agent self-registers with tasks) ───────
class TaskRegistrationItem(BaseModel):
"""A single task an agent wants to register."""
name: str = Field(..., max_length=256)
description: str = Field(default="")
cron_expression: str = Field(..., max_length=64)
grace_period: int = Field(default=300, ge=0)
class AgentBatchRegister(BaseModel):
"""AI agent sends this to register itself + all its tasks in one call."""
name: str = Field(..., max_length=128)
description: str = Field(default="")
tasks: list[TaskRegistrationItem] = Field(default_factory=list)
class AgentBatchRegisterResult(BaseModel):
"""Response for batch registration."""
agent: AgentOut
tasks_created: int
+31
View File
@@ -0,0 +1,31 @@
"""Pydantic schemas — TaskExecution."""
from datetime import datetime
from pydantic import BaseModel, Field
class ExecutionReport(BaseModel):
"""Agent 汇报执行结果"""
status: str = Field(..., pattern=r"^(success|failed)$", description="success 或 failed")
finished_at: datetime | None = None
duration_ms: int | None = None
result: str | None = Field(None, description="执行结果摘要 (JSON)")
log: str | None = Field(None, description="执行日志文本")
error_message: str | None = None
class ExecutionOut(BaseModel):
id: int
task_id: int
agent_id: int
status: str
started_at: datetime
finished_at: datetime | None = None
duration_ms: int | None = None
result: str | None = None
log: str | None = None
error_message: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
+70
View File
@@ -0,0 +1,70 @@
"""Pydantic schemas — Notifications & Alerts."""
from datetime import datetime
from pydantic import BaseModel, Field
# ── Notification Channel ──────────────────────────────────────────────
class NotificationChannelCreate(BaseModel):
name: str = Field(..., max_length=128)
channel_type: str = Field(..., pattern=r"^(feishu_cli|email|webhook)$")
config: str = Field(..., description="JSON 配置字符串")
class NotificationChannelUpdate(BaseModel):
name: str | None = Field(None, max_length=128)
config: str | None = None
enabled: bool | None = None
class NotificationChannelOut(BaseModel):
id: int
name: str
channel_type: str
config: str
enabled: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
# ── Alert Rule ────────────────────────────────────────────────────────
class AlertRuleCreate(BaseModel):
task_id: int
channel_id: int
alert_type: str = Field(default="missed_run", pattern=r"^(missed_run|failure)$")
class AlertRuleOut(BaseModel):
id: int
task_id: int
channel_id: int
channel_name: str = ""
alert_type: str
enabled: bool
created_at: datetime
model_config = {"from_attributes": True}
# ── Alert ─────────────────────────────────────────────────────────────
class AlertOut(BaseModel):
id: int
task_id: int | None = None
task_name: str = ""
alert_type: str
message: str
status: str
acknowledged_at: datetime | None = None
created_at: datetime
model_config = {"from_attributes": True}
class AlertAcknowledge(BaseModel):
pass
+39
View File
@@ -0,0 +1,39 @@
"""Pydantic schemas — ScheduledTask."""
from datetime import datetime
from pydantic import BaseModel, Field
class TaskCreate(BaseModel):
name: str = Field(..., max_length=256)
description: str = Field(default="")
cron_expression: str = Field(..., max_length=64)
grace_period: int = Field(default=300, ge=0)
class TaskUpdate(BaseModel):
name: str | None = Field(None, max_length=256)
description: str | None = None
cron_expression: str | None = None
grace_period: int | None = None
status: str | None = None # active / paused / stopped
class TaskOut(BaseModel):
id: int
agent_id: int
agent_name: str = ""
name: str
description: str
cron_expression: str
grace_period: int
status: str
last_run_at: datetime | None = None
last_run_result: str | None = None
next_run_at: datetime | None = None
total_run_count: int = 0
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+15
View File
@@ -0,0 +1,15 @@
"""Service layer __init__."""
from app.services.agent import AgentService # noqa: F401
from app.services.task import TaskService # noqa: F401
from app.services.execution import ExecutionService # noqa: F401
from app.services.scheduler import SchedulerService # noqa: F401
from app.services.notification import NotificationService # noqa: F401
__all__ = [
"AgentService",
"TaskService",
"ExecutionService",
"SchedulerService",
"NotificationService",
]
+54
View File
@@ -0,0 +1,54 @@
"""Agent business logic."""
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Agent, ScheduledTask
class AgentService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, name: str, description: str = "") -> Agent:
agent = Agent(name=name, description=description)
self.db.add(agent)
await self.db.flush()
await self.db.refresh(agent)
return agent
async def get(self, agent_id: int) -> Agent | None:
return await self.db.get(Agent, agent_id)
async def get_by_api_key(self, api_key: str) -> Agent | None:
stmt = select(Agent).where(Agent.api_key == api_key)
result = await self.db.execute(stmt)
return result.scalar_one_or_none()
async def list_all(self) -> list[Agent]:
stmt = select(Agent).order_by(Agent.id)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def update(self, agent_id: int, **kwargs) -> Agent | None:
agent = await self.get(agent_id)
if agent is None:
return None
for k, v in kwargs.items():
if v is not None and hasattr(agent, k):
setattr(agent, k, v)
await self.db.flush()
return agent
async def heartbeat(self, agent_id: int) -> Agent | None:
from datetime import datetime
agent = await self.get(agent_id)
if agent:
agent.last_heartbeat_at = datetime.utcnow()
await self.db.flush()
return agent
async def get_task_count(self, agent_id: int) -> int:
stmt = select(func.count(ScheduledTask.id)).where(ScheduledTask.agent_id == agent_id)
result = await self.db.execute(stmt)
return result.scalar() or 0
+69
View File
@@ -0,0 +1,69 @@
"""TaskExecution business logic."""
from datetime import datetime
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import TaskExecution, ScheduledTask, Agent
class ExecutionService:
def __init__(self, db: AsyncSession):
self.db = db
async def start_execution(self, task_id: int, agent_id: int) -> TaskExecution:
"""Agent 开始执行任务时创建一个 running 记录."""
exec_record = TaskExecution(task_id=task_id, agent_id=agent_id, status="running")
self.db.add(exec_record)
await self.db.flush()
await self.db.refresh(exec_record)
return exec_record
async def report_result(self, task_id: int, agent_id: int,
status: str, finished_at: datetime | None = None,
duration_ms: int | None = None,
result: str | None = None,
log: str | None = None,
error_message: str | None = None) -> TaskExecution | None:
"""Agent 汇报执行结果:创建一条完成记录并更新任务的 last_run 信息。"""
now = finished_at or datetime.utcnow()
exec_record = TaskExecution(
task_id=task_id,
agent_id=agent_id,
status=status,
started_at=now,
finished_at=now,
duration_ms=duration_ms,
result=result,
log=log,
error_message=error_message,
)
self.db.add(exec_record)
await self.db.flush()
await self.db.refresh(exec_record)
return exec_record
async def get_executions(self, task_id: int, limit: int = 50, offset: int = 0) -> list[TaskExecution]:
stmt = (
select(TaskExecution)
.where(TaskExecution.task_id == task_id)
.order_by(desc(TaskExecution.id))
.offset(offset)
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def get_execution(self, execution_id: int) -> TaskExecution | None:
return await self.db.get(TaskExecution, execution_id)
async def get_recent_executions(self, limit: int = 100) -> list[TaskExecution]:
stmt = (
select(TaskExecution)
.order_by(desc(TaskExecution.id))
.limit(limit)
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
+88
View File
@@ -0,0 +1,88 @@
"""Notification services — send alerts through configured channels."""
import json
import logging
from app.models.notification import NotificationChannel
logger = logging.getLogger("taskpulse.notification")
class NotificationService:
"""Dispatch alert messages through the appropriate channel provider."""
async def send(self, channel: NotificationChannel, message: str):
provider = self._get_provider(channel.channel_type)
config = json.loads(channel.config) if isinstance(channel.config, str) else channel.config
await provider(config, message)
def _get_provider(self, channel_type: str):
providers = {
"feishu_cli": self._send_feishu,
"email": self._send_email,
"webhook": self._send_webhook,
}
provider = providers.get(channel_type)
if not provider:
raise ValueError(f"Unsupported channel type: {channel_type}")
return provider
async def _send_feishu(self, config: dict, message: str):
"""Send via Feishu webhook (supports both CLI-style and webhook)."""
import httpx
webhook_url = config.get("webhook_url", "")
if not webhook_url:
logger.warning("Feishu webhook URL not configured")
return
payload = {
"msg_type": "text",
"content": {"text": message},
}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(webhook_url, json=payload)
resp.raise_for_status()
logger.info("Feishu notification sent: %s", resp.status_code)
async def _send_email(self, config: dict, message: str):
"""Send via SMTP."""
import smtplib
from email.mime.text import MIMEText
smtp_host = config.get("smtp_host", "")
smtp_port = config.get("smtp_port", 587)
smtp_user = config.get("smtp_user", "")
smtp_pass = config.get("smtp_password", "")
to_addr = config.get("to_address", "")
from_addr = config.get("from_address", smtp_user)
if not all([smtp_host, smtp_user, smtp_pass, to_addr]):
logger.warning("Email config incomplete, skipping")
return
msg = MIMEText(message, "plain", "utf-8")
msg["Subject"] = "[TaskPulse] 定时任务告警"
msg["From"] = from_addr
msg["To"] = to_addr
with smtplib.SMTP(smtp_host, smtp_port) as server:
server.starttls()
server.login(smtp_user, smtp_pass)
server.send_message(msg)
logger.info("Email notification sent to %s", to_addr)
async def _send_webhook(self, config: dict, message: str):
"""Send via generic webhook."""
import httpx
url = config.get("url", "")
if not url:
logger.warning("Webhook URL not configured")
return
payload = {"text": message, "source": "taskpulse"}
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
logger.info("Webhook notification sent: %s", resp.status_code)
+103
View File
@@ -0,0 +1,103 @@
"""Scheduler — periodic check for overdue tasks."""
import asyncio
import logging
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import async_session_factory
from app.models import ScheduledTask, Alert, AlertRule, NotificationChannel
logger = logging.getLogger("taskpulse.scheduler")
class SchedulerService:
"""Background checker that scans for overdue tasks and triggers alerts."""
def __init__(self):
self._running = False
async def start(self):
self._running = True
logger.info("Scheduler started (interval=%ss)", settings.SCHEDULER_CHECK_INTERVAL)
while self._running:
try:
await self._check_overdue()
except Exception as exc:
logger.exception("Scheduler check error: %s", exc)
await asyncio.sleep(settings.SCHEDULER_CHECK_INTERVAL)
async def stop(self):
self._running = False
logger.info("Scheduler stopped")
async def _check_overdue(self):
"""Find active tasks past their next_run_at + grace_period."""
async with async_session_factory() as db:
now = datetime.utcnow()
stmt = select(ScheduledTask).where(
ScheduledTask.status == "active",
ScheduledTask.next_run_at.isnot(None),
ScheduledTask.next_run_at < now,
)
result = await db.execute(stmt)
tasks = list(result.scalars().all())
for task in tasks:
grace_end = task.next_run_at.timestamp() + task.grace_period
if now.timestamp() <= grace_end:
continue # still within grace window
# Check if we already alerted recently for this task
recent = select(Alert).where(
Alert.task_id == task.id,
Alert.alert_type == "missed_run",
Alert.status == "pending",
)
existing = (await db.execute(recent)).scalar_one_or_none()
if existing:
continue # already has pending alert
alert = Alert(
task_id=task.id,
alert_type="missed_run",
message=(
f"任务 [{task.name}](ID={task.id}) 超过预定时间未执行。\n"
f"预期执行时间: {task.next_run_at}\n"
f"容忍窗口: {task.grace_period}s\n"
f"当前时间: {now}"
),
status="pending",
)
db.add(alert)
await db.flush()
# Send notifications
await self._notify_for_task(db, task, alert)
await db.commit()
async def _notify_for_task(self, db: AsyncSession, task: ScheduledTask, alert: Alert):
"""Send notifications through all active channels for this task."""
from app.services.notification import NotificationService
rules_q = select(AlertRule).where(
AlertRule.task_id == task.id,
AlertRule.enabled == True,
AlertRule.alert_type == "missed_run",
)
rules = (await db.execute(rules_q)).scalars().all()
notifier = NotificationService()
for rule in rules:
channel = await db.get(NotificationChannel, rule.channel_id)
if channel and channel.enabled:
try:
await notifier.send(channel, alert.message)
logger.info("Alert sent for task %s via %s", task.name, channel.name)
except Exception as exc:
logger.error("Failed to send alert for task %s via %s: %s",
task.name, channel.name, exc)
+102
View File
@@ -0,0 +1,102 @@
"""ScheduledTask business logic."""
from datetime import datetime
from croniter import croniter
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import ScheduledTask, Agent
class TaskService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, agent_id: int, name: str, cron_expression: str,
description: str = "", grace_period: int = 300) -> ScheduledTask:
now = datetime.utcnow()
next_run = self._calc_next_run(cron_expression, now)
task = ScheduledTask(
agent_id=agent_id,
name=name,
description=description,
cron_expression=cron_expression,
grace_period=grace_period,
next_run_at=next_run,
)
self.db.add(task)
await self.db.flush()
await self.db.refresh(task)
return task
async def get(self, task_id: int) -> ScheduledTask | None:
return await self.db.get(ScheduledTask, task_id)
async def list_by_agent(self, agent_id: int) -> list[ScheduledTask]:
stmt = select(ScheduledTask).where(ScheduledTask.agent_id == agent_id).order_by(ScheduledTask.id)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def list_all(self, status: str | None = None) -> list[ScheduledTask]:
stmt = select(ScheduledTask)
if status:
stmt = stmt.where(ScheduledTask.status == status)
stmt = stmt.order_by(ScheduledTask.id)
result = await self.db.execute(stmt)
return list(result.scalars().all())
async def update(self, task_id: int, **kwargs) -> ScheduledTask | None:
task = await self.get(task_id)
if task is None:
return None
for k, v in kwargs.items():
if v is not None and hasattr(task, k):
setattr(task, k, v)
# Recalculate next_run if cron changed
if kwargs.get("cron_expression"):
task.next_run_at = self._calc_next_run(task.cron_expression, datetime.utcnow())
await self.db.flush()
await self.db.refresh(task)
return task
async def delete(self, task_id: int) -> bool:
task = await self.get(task_id)
if task is None:
return False
await self.db.delete(task)
await self.db.flush()
return True
async def mark_run(self, task_id: int, success: bool) -> ScheduledTask | None:
"""Mark that a task just ran: update last_run, total_count, next_run."""
task = await self.get(task_id)
if task is None:
return None
now = datetime.utcnow()
task.last_run_at = now
task.last_run_result = "success" if success else "failed"
task.total_run_count = (task.total_run_count or 0) + 1
task.next_run_at = self._calc_next_run(task.cron_expression, now)
await self.db.flush()
await self.db.refresh(task)
return task
async def get_overdue_tasks(self, grace_seconds: int) -> list[ScheduledTask]:
"""Find active tasks whose next_run_at + grace_period is in the past."""
now = datetime.utcnow()
stmt = select(ScheduledTask).where(
ScheduledTask.status == "active",
ScheduledTask.next_run_at.isnot(None),
ScheduledTask.next_run_at + func.make_interval(0, 0, 0, 0, 0, 0, grace_seconds) < now,
)
result = await self.db.execute(stmt)
return list(result.scalars().all())
@staticmethod
def _calc_next_run(cron_expression: str, base: datetime | None = None) -> datetime | None:
try:
cron = croniter(cron_expression, base or datetime.utcnow())
return cron.get_next(datetime)
except (ValueError, KeyError):
return None