feat: initial TaskPulse release
AI Agent 定时任务跟踪、监控、状态看板系统。 - FastAPI + Vue3 单体应用 - AI Agent 一键接入(批量注册 API) - 暗色主题 Dashboard - 后台调度器 + 超时告警 - 飞书/邮件/Webhook 多渠道通知 - Base URL 可配置 - Gunicorn 生产部署
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user