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
+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