定时任务订阅模式

This commit is contained in:
吉鑫升 committed 2026-05-13 14:59:27 +08:00
1 parent 839386f0b4
commit aa034f8669
14 files changed
+1143 -98

No files matched your search

@@ -2,10 +2,12 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
PublishedScheduledTask,
ScheduledTask,
ScheduledTaskDeliveryProfile,
ScheduledTaskResultFileContent,
ScheduledTaskRun,
ScheduledTaskSubscription,
SchedulerThread,
} from "./types";
@@ -214,3 +216,57 @@ export async function triggerScheduledTask(taskId: string) {
}),
);
}
export async function listPublishedScheduledTasks(params: { limit?: number; offset?: number } = {}) {
const query = new URLSearchParams();
if (params.limit != null) query.set("limit", String(params.limit));
if (params.offset != null) query.set("offset", String(params.offset));
const suffix = query.toString();
return parseResponse<PublishedScheduledTask[]>(
await apiFetch(`${baseUrl()}/published${suffix ? `?${suffix}` : ""}`),
);
}
export async function publishScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/publish`, {
method: "POST",
}),
);
}
export async function unpublishScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/unpublish`, {
method: "POST",
}),
);
}
export async function subscribeScheduledTask(taskId: string, notifyElement: boolean) {
return parseResponse<ScheduledTaskSubscription>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notify_element: notifyElement }),
}),
);
}
export async function updateScheduledTaskSubscription(taskId: string, notifyElement: boolean) {
return parseResponse<ScheduledTaskSubscription>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notify_element: notifyElement }),
}),
);
}
export async function unsubscribeScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "DELETE",
}),
);
}
@@ -7,13 +7,19 @@ import {
getSchedulerThread,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
listPublishedScheduledTasks,
listScheduledTaskRuns,
listScheduledTasks,
pauseScheduledTask,
publishScheduledTask,
resumeScheduledTask,
subscribeScheduledTask,
triggerScheduledTask,
unpublishScheduledTask,
unsubscribeScheduledTask,
updateScheduledTaskDeliveryProfile,
updateScheduledTask,
updateScheduledTaskSubscription,
} from "./api";
export function useSchedulerThread() {
@@ -58,6 +64,15 @@ export function useScheduledTaskDeliveryProfile() {
});
}
export function usePublishedScheduledTasks(enabled: boolean = true) {
return useQuery({
queryKey: ["scheduled-tasks", "published"],
queryFn: () => listPublishedScheduledTasks({ limit: 100 }),
enabled,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskActions() {
const queryClient = useQueryClient();
const invalidate = () =>
@@ -107,5 +122,27 @@ export function useScheduledTaskActions() {
queryKey: ["scheduled-tasks", "delivery-profile"],
}),
}),
publish: useMutation({
mutationFn: publishScheduledTask,
onSuccess: invalidate,
}),
unpublish: useMutation({
mutationFn: unpublishScheduledTask,
onSuccess: invalidate,
}),
subscribe: useMutation({
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
subscribeScheduledTask(taskId, notifyElement),
onSuccess: invalidate,
}),
updateSubscription: useMutation({
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
updateScheduledTaskSubscription(taskId, notifyElement),
onSuccess: invalidate,
}),
unsubscribe: useMutation({
mutationFn: unsubscribeScheduledTask,
onSuccess: invalidate,
}),
};
}
@@ -1,3 +1,14 @@
export type ScheduledTaskRole = "owner" | "subscriber";
export type ScheduledTaskSubscription = {
id: string;
task_id: string;
user_id: string;
notify_element: boolean;
created_at?: string | null;
updated_at?: string | null;
};
export type ScheduledTask = {
task_id: string;
name: string;
@@ -12,6 +23,20 @@ export type ScheduledTask = {
source_agent_name?: string | null;
execution_agent_name?: string | null;
scheduler_thread_id: string;
published?: boolean;
published_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
};
export type PublishedScheduledTask = {
task_id: string;
name: string;
prompt_summary: string;
schedule_text: string;
timezone: string;
published_at?: string | null;
owner_user_id: string;
};
export type SchedulerThread = {
+305 -84
View File
@@ -2,9 +2,15 @@
import { useEffect, useMemo, useState } from "react";
import {
BellOff,
BellRing,
Download,
Edit3,
Eye,
Globe,
Heart,
HeartOff,
Lock,
Pause,
Play,
Plus,
@@ -21,6 +27,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
@@ -30,11 +38,13 @@ import {
import { useAgents } from "@/core/agents";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
type ScheduledTask,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
useScheduledTasks,
usePublishedScheduledTasks,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
@@ -217,6 +227,9 @@ export default function ScheduledTasksPage() {
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
const { data: published = [], isLoading: publishedLoading } =
usePublishedScheduledTasks(activeTab === "plaza");
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [elementUserId, setElementUserId] = useState("");
const [editing, setEditing] = useState<ScheduledTask | null>(null);
@@ -224,6 +237,8 @@ export default function ScheduledTasksPage() {
const [form, setForm] = useState(emptyForm);
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const sortedTasks = useMemo(
() =>
@@ -377,95 +392,232 @@ export default function ScheduledTasksPage() {
保存
</Button>
</div>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<TabsList className="w-full">
<TabsTrigger value="mine" className="flex-1">我的任务</TabsTrigger>
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-3 p-4 pt-0">
{isLoading && (
<div className="text-muted-foreground text-sm">正在加载任务...</div>
)}
{!isLoading && sortedTasks.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
还没有定时任务。点击新建创建第一个任务。
</div>
)}
{sortedTasks.map((task) => (
<button
key={task.task_id}
className={cn(
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
selectedTaskId === task.task_id && "border-primary bg-accent",
)}
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{task.name}</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{task.schedule_text} · {task.timezone}
{activeTab === "mine" ? (
<div className="flex flex-col gap-3 p-4 pt-0">
{isLoading && (
<div className="text-muted-foreground text-sm">正在加载任务...</div>
)}
{!isLoading && sortedTasks.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。
</div>
)}
{sortedTasks.map((task) => {
const isOwner = task.role !== "subscriber";
const isSubscriber = task.role === "subscriber";
const notifyOn = !!task._subscription?.notify_element;
return (
<button
key={task.task_id}
className={cn(
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
selectedTaskId === task.task_id && "border-primary bg-accent",
)}
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate font-medium">{task.name}</div>
{isSubscriber && (
<Badge variant="outline" className="shrink-0 text-xs">
<Heart className="size-3" />收藏
</Badge>
)}
{isOwner && task.published && (
<Badge variant="outline" className="shrink-0 text-xs">
<Globe className="size-3" />已发布
</Badge>
)}
</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{task.schedule_text} · {task.timezone}
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"}>
{task.enabled ? "启用" : "暂停"}
</Badge>
</div>
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{isOwner ? (
<>
<Button
size="icon"
variant="outline"
title="编辑"
onClick={(event) => {
event.stopPropagation();
openEdit(task);
}}
>
<Edit3 className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
title={task.enabled ? "暂停" : "恢复"}
onClick={(event) => {
event.stopPropagation();
task.enabled
? actions.pause.mutate(task.task_id)
: actions.resume.mutate(task.task_id);
}}
>
{task.enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
<Button
size="icon"
variant="outline"
title="立即执行"
onClick={(event) => {
event.stopPropagation();
actions.trigger.mutate(task.task_id);
toast.success("已触发执行");
}}
>
<RefreshCw className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
title={task.published ? "取消发布" : "发布到广场"}
onClick={async (event) => {
event.stopPropagation();
try {
if (task.published) {
await actions.unpublish.mutateAsync(task.task_id);
toast.success("已取消发布(已订阅者保留)");
} else {
await actions.publish.mutateAsync(task.task_id);
toast.success("已发布到广场");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}}
>
{task.published ? <Lock className="size-4" /> : <Globe className="size-4" />}
</Button>
<Button
size="icon"
variant="destructive"
title="删除"
onClick={(event) => {
event.stopPropagation();
actions.remove.mutate(task.task_id);
}}
>
<Trash2 className="size-4" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
title={notifyOn ? "关闭通知" : "开启通知"}
onClick={async (event) => {
event.stopPropagation();
try {
await actions.updateSubscription.mutateAsync({
taskId: task.task_id,
notifyElement: !notifyOn,
});
toast.success(notifyOn ? "已关闭 Element 通知" : "已开启 Element 通知");
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}}
>
{notifyOn ? <BellRing className="size-4" /> : <BellOff className="size-4" />}
{notifyOn ? "通知中" : "未通知"}
</Button>
<Button
size="sm"
variant="outline"
title="取消收藏"
onClick={async (event) => {
event.stopPropagation();
try {
await actions.unsubscribe.mutateAsync(task.task_id);
toast.success("已取消收藏");
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}}
>
<HeartOff className="size-4" />取消收藏
</Button>
</>
)}
</div>
</button>
);
})}
</div>
) : (
<div className="flex flex-col gap-3 p-4 pt-0">
{publishedLoading && (
<div className="text-muted-foreground text-sm">正在加载广场任务...</div>
)}
{!publishedLoading && published.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
广场上还没有发布的任务。
</div>
)}
{published.map((item) => {
const subscribed = tasks.some((t) => t.task_id === item.task_id);
return (
<div key={item.task_id} className="bg-card rounded-md border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{item.name}</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{item.schedule_text} · {item.timezone}
</div>
</div>
{subscribed && (
<Badge variant="secondary" className="shrink-0 text-xs">
<Heart className="size-3" />已收藏
</Badge>
)}
</div>
<div className="text-muted-foreground mt-3 line-clamp-3 text-xs whitespace-pre-wrap">
{item.prompt_summary || "(无摘要)"}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant={subscribed ? "outline" : "default"}
disabled={subscribed}
onClick={() => {
setSubscribeTarget(item);
setSubscribeNotify(false);
}}
>
<Heart className="size-4" />
{subscribed ? "已收藏" : "收藏到我的任务"}
</Button>
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"}>
{task.enabled ? "启用" : "暂停"}
</Badge>
</div>
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="icon"
variant="outline"
title="编辑"
onClick={(event) => {
event.stopPropagation();
openEdit(task);
}}
>
<Edit3 className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
title={task.enabled ? "暂停" : "恢复"}
onClick={(event) => {
event.stopPropagation();
task.enabled
? actions.pause.mutate(task.task_id)
: actions.resume.mutate(task.task_id);
}}
>
{task.enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
<Button
size="icon"
variant="outline"
title="立即执行"
onClick={(event) => {
event.stopPropagation();
actions.trigger.mutate(task.task_id);
toast.success("已触发执行");
}}
>
<RefreshCw className="size-4" />
</Button>
<Button
size="icon"
variant="destructive"
title="删除"
onClick={(event) => {
event.stopPropagation();
actions.remove.mutate(task.task_id);
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</button>
))}
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
@@ -566,6 +718,75 @@ export default function ScheduledTasksPage() {
</div>
</WorkspaceBody>
<Dialog
open={!!subscribeTarget}
onOpenChange={(open) => {
if (!open) {
setSubscribeTarget(null);
setSubscribeNotify(false);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>收藏到我的任务</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<div className="font-medium">{subscribeTarget?.name}</div>
<div className="text-muted-foreground mt-1 text-xs">
{subscribeTarget?.schedule_text} · {subscribeTarget?.timezone}
</div>
</div>
<div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap">
{subscribeTarget?.prompt_summary || "(无摘要)"}
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm font-medium">同步接收 Element 通知</div>
<div className="text-muted-foreground mt-1 text-xs">
开启后,每次执行结果将复制一份发送到你设置的 Element 用户。
{!deliveryProfile?.element_user_id && (
<span className="text-destructive"> 你尚未配置 Element 用户 ID。</span>
)}
</div>
</div>
<Switch checked={subscribeNotify} onCheckedChange={setSubscribeNotify} />
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setSubscribeTarget(null);
setSubscribeNotify(false);
}}
>
取消
</Button>
<Button
onClick={async () => {
if (!subscribeTarget) return;
try {
await actions.subscribe.mutateAsync({
taskId: subscribeTarget.task_id,
notifyElement: subscribeNotify,
});
toast.success("已收藏到我的任务");
setSubscribeTarget(null);
setSubscribeNotify(false);
setActiveTab("mine");
} catch (error) {
toast.error(error instanceof Error ? error.message : "收藏失败");
}
}}
>
确认收藏
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
@@ -43,6 +43,10 @@ class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
element_user_id: str | None = None
class ScheduledTaskSubscribeRequest(BaseModel):
notify_element: bool = False
async def _user_id(request: Request) -> str:
user_id = await get_current_user(request)
if not user_id:
@@ -325,3 +329,63 @@ async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True, "task": task}
# --- Publish / subscribe ---
@router.get("/published")
async def list_published_scheduled_tasks(request: Request, limit: int = 50, offset: int = 0) -> list[dict]:
service = get_scheduled_task_service()
await _user_id(request)
return await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
@router.post("/{task_id}/publish")
async def publish_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.publish_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/unpublish")
async def unpublish_scheduled_task(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.unpublish_task(user_id, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return task
@router.post("/{task_id}/subscribe")
async def subscribe_scheduled_task(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element)
if sub is None:
raise HTTPException(status_code=404, detail="Task is not available for subscription")
return sub
@router.patch("/{task_id}/subscribe")
async def update_scheduled_task_subscription(task_id: str, body: ScheduledTaskSubscribeRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
sub = await service.subscribe_task(user_id, task_id, notify_element=body.notify_element)
if sub is None:
raise HTTPException(status_code=404, detail="Subscription not found")
return sub
@router.delete("/{task_id}/subscribe")
async def delete_scheduled_task_subscription(task_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.unsubscribe_task(user_id, task_id)
if not deleted:
raise HTTPException(status_code=404, detail="Subscription not found")
return {"success": True}
@@ -0,0 +1,66 @@
"""Publish/subscribe support for scheduled tasks.
Revision ID: 20260513_02
Revises: 20260513_01
Create Date: 2026-05-13
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260513_02"
down_revision = "20260513_01"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if not _has_column("scheduled_tasks", "published"):
op.add_column(
"scheduled_tasks",
sa.Column("published", sa.Boolean(), nullable=False, server_default=sa.false()),
)
op.create_index("ix_scheduled_tasks_published", "scheduled_tasks", ["published"])
if not _has_column("scheduled_tasks", "published_at"):
op.add_column(
"scheduled_tasks",
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
)
if not _has_table("scheduled_task_subscriptions"):
op.create_table(
"scheduled_task_subscriptions",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("task_id", sa.String(64), nullable=False),
sa.Column("user_id", sa.String(64), nullable=False),
sa.Column("notify_element", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("task_id", "user_id", name="uq_scheduled_task_subscriptions_task_user"),
)
op.create_index("ix_scheduled_task_subscriptions_task_id", "scheduled_task_subscriptions", ["task_id"])
op.create_index("ix_scheduled_task_subscriptions_user_id", "scheduled_task_subscriptions", ["user_id"])
def downgrade() -> None:
if _has_table("scheduled_task_subscriptions"):
op.drop_table("scheduled_task_subscriptions")
if _has_column("scheduled_tasks", "published_at"):
op.drop_column("scheduled_tasks", "published_at")
if _has_column("scheduled_tasks", "published"):
op.drop_index("ix_scheduled_tasks_published", table_name="scheduled_tasks")
op.drop_column("scheduled_tasks", "published")
@@ -19,7 +19,13 @@ from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
ScheduledTaskRow,
ScheduledTaskRunRow,
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.model import UserRow
@@ -31,6 +37,7 @@ __all__ = [
"ScheduledTaskDeliveryProfileRow",
"ScheduledTaskRow",
"ScheduledTaskRunRow",
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"ThreadMetaRow",
"UserRow",
@@ -4,7 +4,13 @@ from __future__ import annotations
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
from deerflow.persistence.scheduled_tasks.memory import MemoryScheduledTaskStore
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
ScheduledTaskRow,
ScheduledTaskRunRow,
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
from deerflow.persistence.scheduled_tasks.sql import ScheduledTaskRepository
__all__ = [
@@ -14,6 +20,7 @@ __all__ = [
"ScheduledTaskRow",
"ScheduledTaskRunRow",
"ScheduledTaskStore",
"ScheduledTaskSubscriptionRow",
"SchedulerThreadRow",
"make_scheduled_task_store",
]
@@ -63,3 +63,46 @@ class ScheduledTaskStore(ABC):
@abstractmethod
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
raise NotImplementedError
# --- Publish / subscribe ---
@abstractmethod
async def get_task_any(self, task_id: str) -> dict[str, Any] | None:
"""Return a task by id without restricting by owner. Used for publish/subscribe lookups."""
raise NotImplementedError
@abstractmethod
async def get_run_any(self, run_id: str) -> dict[str, Any] | None:
"""Return a run by id without restricting by owner. Used for subscriber visibility."""
raise NotImplementedError
@abstractmethod
async def set_task_published(self, task_id: str, owner_user_id: str, *, published: bool) -> dict[str, Any] | None:
"""Toggle published flag. Returns updated task or ``None`` if missing / not owned."""
raise NotImplementedError
@abstractmethod
async def list_published_tasks(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
raise NotImplementedError
@abstractmethod
async def list_subscribed_tasks(self, user_id: str) -> list[dict[str, Any]]:
"""Return tasks the user has subscribed to. Each item carries ``_subscription`` dict."""
raise NotImplementedError
@abstractmethod
async def get_subscription(self, task_id: str, user_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def upsert_subscription(self, task_id: str, user_id: str, *, notify_element: bool) -> dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def delete_subscription(self, task_id: str, user_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def list_notify_subscribers(self, task_id: str) -> list[dict[str, Any]]:
"""Subscribers with ``notify_element=True`` for the given task."""
raise NotImplementedError
@@ -2,7 +2,8 @@
from __future__ import annotations
from datetime import datetime
import uuid
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
@@ -15,6 +16,7 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
self._runs_by_key: set[tuple[str, str]] = set()
self._runs: dict[str, dict[str, Any]] = {}
self._profiles: dict[str, dict[str, Any]] = {}
self._subscriptions: dict[tuple[str, str], dict[str, Any]] = {}
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
if user_id not in self._threads:
@@ -105,3 +107,73 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
if "element_room_id" in data:
profile["element_room_id"] = data["element_room_id"] or None
return dict(profile)
# --- Publish / subscribe ---
async def get_task_any(self, task_id: str) -> dict[str, Any] | None:
task = self._tasks.get(task_id)
return dict(task) if task is not None else None
async def get_run_any(self, run_id: str) -> dict[str, Any] | None:
run = self._runs.get(run_id)
return dict(run) if run is not None else None
async def set_task_published(self, task_id: str, owner_user_id: str, *, published: bool) -> dict[str, Any] | None:
task = self._tasks.get(task_id)
if task is None or task["user_id"] != owner_user_id:
return None
task["published"] = bool(published)
task["published_at"] = datetime.now(UTC) if published else None
return dict(task)
async def list_published_tasks(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
tasks = [dict(task) for task in self._tasks.values() if task.get("published")]
tasks.sort(key=lambda t: (t.get("published_at") or t.get("created_at") or ""), reverse=True)
return tasks[max(0, offset) : max(0, offset) + max(1, limit)]
async def list_subscribed_tasks(self, user_id: str) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for (task_id, sub_user_id), sub in self._subscriptions.items():
if sub_user_id != user_id:
continue
task = self._tasks.get(task_id)
if task is None:
continue
payload = dict(task)
payload["_subscription"] = dict(sub)
out.append(payload)
out.sort(key=lambda item: item["_subscription"].get("created_at") or "", reverse=True)
return out
async def get_subscription(self, task_id: str, user_id: str) -> dict[str, Any] | None:
sub = self._subscriptions.get((task_id, user_id))
return dict(sub) if sub is not None else None
async def upsert_subscription(self, task_id: str, user_id: str, *, notify_element: bool) -> dict[str, Any]:
key = (task_id, user_id)
existing = self._subscriptions.get(key)
now = datetime.now(UTC)
if existing is None:
sub = {
"id": str(uuid.uuid4()),
"task_id": task_id,
"user_id": user_id,
"notify_element": bool(notify_element),
"created_at": now,
"updated_at": now,
}
self._subscriptions[key] = sub
return dict(sub)
existing["notify_element"] = bool(notify_element)
existing["updated_at"] = now
return dict(existing)
async def delete_subscription(self, task_id: str, user_id: str) -> bool:
return self._subscriptions.pop((task_id, user_id), None) is not None
async def list_notify_subscribers(self, task_id: str) -> list[dict[str, Any]]:
return [
dict(sub)
for (tid, _uid), sub in self._subscriptions.items()
if tid == task_id and sub.get("notify_element")
]
@@ -39,6 +39,20 @@ class ScheduledTaskRow(Base):
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_status: Mapped[str | None] = mapped_column(String(32))
execution_context_json: Mapped[dict] = mapped_column(JSON, default=dict)
published: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class ScheduledTaskSubscriptionRow(Base):
__tablename__ = "scheduled_task_subscriptions"
__table_args__ = (UniqueConstraint("task_id", "user_id", name="uq_scheduled_task_subscriptions_task_user"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(64), index=True)
user_id: Mapped[str] = mapped_column(String(64), index=True)
notify_element: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -5,17 +5,25 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, update
import uuid
from sqlalchemy import delete, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskDeliveryProfileRow, ScheduledTaskRow, ScheduledTaskRunRow, SchedulerThreadRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
ScheduledTaskRow,
ScheduledTaskRunRow,
ScheduledTaskSubscriptionRow,
SchedulerThreadRow,
)
def _to_dict(row) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at", "next_run_at", "last_run_at", "scheduled_for", "started_at", "finished_at"):
for key in ("created_at", "updated_at", "next_run_at", "last_run_at", "scheduled_for", "started_at", "finished_at", "published_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
@@ -194,3 +202,107 @@ class ScheduledTaskRepository(ScheduledTaskStore):
await session.commit()
await session.refresh(row)
return _to_dict(row)
# --- Publish / subscribe ---
async def get_task_any(self, task_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
return _to_dict(row) if row is not None else None
async def get_run_any(self, run_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRunRow, run_id)
return _to_dict(row) if row is not None else None
async def set_task_published(self, task_id: str, owner_user_id: str, *, published: bool) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None or row.user_id != owner_user_id:
return None
row.published = bool(published)
row.published_at = datetime.now(UTC) if published else None
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def list_published_tasks(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(ScheduledTaskRow)
.where(ScheduledTaskRow.published.is_(True))
.order_by(ScheduledTaskRow.published_at.desc().nullslast(), ScheduledTaskRow.created_at.desc())
.offset(max(0, offset))
.limit(max(1, limit))
)
return [_to_dict(row) for row in result.scalars()]
async def list_subscribed_tasks(self, user_id: str) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(ScheduledTaskRow, ScheduledTaskSubscriptionRow)
.join(ScheduledTaskSubscriptionRow, ScheduledTaskSubscriptionRow.task_id == ScheduledTaskRow.task_id)
.where(ScheduledTaskSubscriptionRow.user_id == user_id)
.order_by(ScheduledTaskSubscriptionRow.created_at.desc())
)
tasks: list[dict[str, Any]] = []
for task_row, sub_row in result.all():
task = _to_dict(task_row)
task["_subscription"] = _to_dict(sub_row)
tasks.append(task)
return tasks
async def get_subscription(self, task_id: str, user_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
result = await session.execute(
select(ScheduledTaskSubscriptionRow)
.where(ScheduledTaskSubscriptionRow.task_id == task_id, ScheduledTaskSubscriptionRow.user_id == user_id)
.limit(1)
)
row = result.scalar_one_or_none()
return _to_dict(row) if row is not None else None
async def upsert_subscription(self, task_id: str, user_id: str, *, notify_element: bool) -> dict[str, Any]:
async with self._sf() as session:
result = await session.execute(
select(ScheduledTaskSubscriptionRow)
.where(ScheduledTaskSubscriptionRow.task_id == task_id, ScheduledTaskSubscriptionRow.user_id == user_id)
.limit(1)
)
row = result.scalar_one_or_none()
if row is None:
row = ScheduledTaskSubscriptionRow(
id=str(uuid.uuid4()),
task_id=task_id,
user_id=user_id,
notify_element=bool(notify_element),
)
session.add(row)
else:
row.notify_element = bool(notify_element)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def delete_subscription(self, task_id: str, user_id: str) -> bool:
async with self._sf() as session:
result = await session.execute(
delete(ScheduledTaskSubscriptionRow).where(
ScheduledTaskSubscriptionRow.task_id == task_id,
ScheduledTaskSubscriptionRow.user_id == user_id,
)
)
await session.commit()
return (result.rowcount or 0) > 0
async def list_notify_subscribers(self, task_id: str) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(ScheduledTaskSubscriptionRow).where(
ScheduledTaskSubscriptionRow.task_id == task_id,
ScheduledTaskSubscriptionRow.notify_element.is_(True),
)
)
return [_to_dict(row) for row in result.scalars()]
@@ -197,16 +197,70 @@ class ScheduledTaskService:
return thread
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
return await self.store.list_tasks(user_id)
owned = await self.store.list_tasks(user_id)
for task in owned:
task["role"] = "owner"
subscribed = await self.store.list_subscribed_tasks(user_id)
owned_ids = {task["task_id"] for task in owned}
for task in subscribed:
if task["task_id"] in owned_ids:
continue
task["role"] = "subscriber"
owned.append(task)
return owned
async def list_published(self, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
"""Public discovery list. Strips author prompt details to a short summary."""
tasks = await self.store.list_published_tasks(limit=limit, offset=offset)
result: list[dict[str, Any]] = []
for task in tasks:
prompt = (task.get("prompt") or "").strip()
summary = prompt if len(prompt) <= 200 else prompt[:200].rstrip() + "…"
result.append(
{
"task_id": task["task_id"],
"name": task.get("name"),
"prompt_summary": summary,
"schedule_text": task.get("schedule_text"),
"timezone": task.get("timezone"),
"published_at": task.get("published_at"),
"owner_user_id": task.get("user_id"),
}
)
return result
async def _accessible_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
task = await self.store.get_task_any(task_id)
if task is None:
return None
if task["user_id"] == user_id:
task["role"] = "owner"
return task
sub = await self.store.get_subscription(task_id, user_id)
if sub is None:
return None
task["role"] = "subscriber"
task["_subscription"] = sub
return task
async def list_runs(self, user_id: str, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
task = await self.store.get_task(task_id, user_id)
if task is None:
accessible = await self._accessible_task(user_id, task_id)
if accessible is None:
return []
return await self.store.list_runs(task_id, user_id, limit=limit)
return await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
return await self.store.get_run(run_id, user_id)
run = await self.store.get_run(run_id, user_id)
if run is not None:
return run
# Subscriber visibility: look up the run unfiltered, then verify the
# caller has a subscription on the parent task.
candidate = await self.store.get_run_any(run_id)
if candidate is None:
return None
if await self.store.get_subscription(candidate["task_id"], user_id) is None:
return None
return candidate
async def delete_run(self, user_id: str, run_id: str) -> bool:
deleted = await self.store.delete_run(run_id, user_id)
@@ -655,15 +709,62 @@ class ScheduledTaskService:
async def _deliver_element_result(self, task: dict[str, Any], result_text: str) -> None:
if self._element_client is None:
return
profile = await self.store.get_delivery_profile(task["user_id"])
body = f"【定时任务】{task['name']}\n\n{result_text}"
# 1) Author
await self._deliver_to_user(task["user_id"], task, body)
# 2) Subscribers who opted into notifications
try:
subscribers = await self.store.list_notify_subscribers(task["task_id"])
except Exception:
logger.exception("Failed to list scheduled task subscribers for %s", task["task_id"])
subscribers = []
for sub in subscribers:
sub_user_id = sub.get("user_id")
if not sub_user_id or sub_user_id == task["user_id"]:
continue
await self._deliver_to_user(sub_user_id, task, body)
async def _deliver_to_user(self, user_id: str, task: dict[str, Any], body: str) -> None:
if self._element_client is None:
return
try:
profile = await self.store.get_delivery_profile(user_id)
except Exception:
logger.exception("Failed to load delivery profile for user %s", user_id)
return
element_user_id = profile.get("element_user_id")
if not isinstance(element_user_id, str) or not element_user_id.strip():
return
existing_room_id: str | None = profile.get("element_room_id") or None
body = f"【定时任务】{task['name']}\n\n{result_text}"
try:
room_id = await self._element_client.send_direct_message(element_user_id.strip(), body, room_id=existing_room_id)
if room_id and room_id != existing_room_id:
await self.store.update_delivery_profile(task["user_id"], {"element_room_id": room_id})
await self.store.update_delivery_profile(user_id, {"element_room_id": room_id})
except Exception:
logger.exception("Failed to deliver scheduled task %s to Element user %s", task["task_id"], element_user_id)
# --- Publish / subscribe ---
async def publish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
return await self.store.set_task_published(task_id, user_id, published=True)
async def unpublish_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
return await self.store.set_task_published(task_id, user_id, published=False)
async def subscribe_task(
self, user_id: str, task_id: str, *, notify_element: bool
) -> dict[str, Any] | None:
task = await self.store.get_task_any(task_id)
if task is None:
return None
if task["user_id"] == user_id:
# Authors don't need to subscribe to their own tasks.
return None
existing = await self.store.get_subscription(task_id, user_id)
if existing is None and not task.get("published"):
# First-time subscription requires the task to currently be published.
return None
return await self.store.upsert_subscription(task_id, user_id, notify_element=notify_element)
async def unsubscribe_task(self, user_id: str, task_id: str) -> bool:
return await self.store.delete_subscription(task_id, user_id)
@@ -0,0 +1,220 @@
"""Tests for scheduled task publish/subscribe + mirrored Element delivery."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
from deerflow.runtime.scheduler.service import ScheduledTaskService
@pytest.fixture
def store() -> MemoryScheduledTaskStore:
return MemoryScheduledTaskStore()
def _seed_task(
store: MemoryScheduledTaskStore,
*,
task_id: str,
user_id: str,
name: str = "demo",
prompt: str = "Hello world",
published: bool = False,
) -> dict[str, Any]:
task = {
"task_id": task_id,
"user_id": user_id,
"scheduler_thread_id": f"thread-{user_id}",
"name": name,
"prompt": prompt,
"schedule_text": "0 9 * * *",
"cron_expr": "0 9 * * *",
"timezone": "UTC",
"enabled": True,
"next_run_at": datetime.now(UTC),
"execution_context": {},
"published": published,
"published_at": datetime.now(UTC) if published else None,
}
store._tasks[task_id] = task # noqa: SLF001
return task
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
# Build with no FastAPI app — the methods we exercise here don't touch app.state.
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
service._element_client = None # noqa: SLF001 — disable real delivery
return service
@pytest.mark.asyncio
async def test_publish_unpublish_only_owner(store):
task = _seed_task(store, task_id="t1", user_id="alice")
service = _build_service(store)
assert await service.publish_task("bob", task["task_id"]) is None, "non-owner cannot publish"
published = await service.publish_task("alice", task["task_id"])
assert published is not None
assert published["published"] is True
assert published["published_at"] is not None
listed = await service.list_published(limit=10, offset=0)
assert len(listed) == 1
assert listed[0]["task_id"] == "t1"
assert listed[0]["prompt_summary"] == "Hello world"
assert "prompt" not in listed[0], "raw prompt must not leak through summary endpoint"
unpublished = await service.unpublish_task("alice", task["task_id"])
assert unpublished["published"] is False
assert (await service.list_published()) == []
@pytest.mark.asyncio
async def test_subscribe_requires_published_for_first_time(store):
task = _seed_task(store, task_id="t1", user_id="alice", published=False)
service = _build_service(store)
# Cannot subscribe to a private task.
assert await service.subscribe_task("bob", task["task_id"], notify_element=False) is None
# Author publishes; bob subscribes.
await service.publish_task("alice", task["task_id"])
sub = await service.subscribe_task("bob", task["task_id"], notify_element=True)
assert sub is not None and sub["notify_element"] is True
# Author cannot subscribe to their own task.
assert await service.subscribe_task("alice", task["task_id"], notify_element=True) is None
@pytest.mark.asyncio
async def test_unpublish_keeps_existing_subscribers(store):
task = _seed_task(store, task_id="t1", user_id="alice", published=True)
service = _build_service(store)
sub = await service.subscribe_task("bob", task["task_id"], notify_element=False)
assert sub is not None
# Author un-publishes — bob keeps acquired access.
await service.unpublish_task("alice", task["task_id"])
assert await store.get_subscription(task["task_id"], "bob") is not None
# New user cannot subscribe after un-publish.
assert await service.subscribe_task("carol", task["task_id"], notify_element=False) is None
# Existing subscriber can still toggle their notification preference.
updated = await service.subscribe_task("bob", task["task_id"], notify_element=True)
assert updated is not None and updated["notify_element"] is True
@pytest.mark.asyncio
async def test_list_tasks_includes_subscribed_with_role(store):
_seed_task(store, task_id="t1", user_id="alice", published=True)
_seed_task(store, task_id="t2", user_id="bob")
service = _build_service(store)
await service.subscribe_task("bob", "t1", notify_element=False)
bobs = await service.list_tasks("bob")
by_id = {t["task_id"]: t for t in bobs}
assert by_id["t2"]["role"] == "owner"
assert by_id["t1"]["role"] == "subscriber"
assert by_id["t1"]["_subscription"]["notify_element"] is False
@pytest.mark.asyncio
async def test_subscriber_can_read_runs_and_get_run(store):
_seed_task(store, task_id="t1", user_id="alice", published=True)
service = _build_service(store)
await service.subscribe_task("bob", "t1", notify_element=False)
# Seed a run owned by alice.
scheduled_for = datetime.now(UTC)
run = await store.create_run_once(
{
"id": "run-1",
"task_id": "t1",
"user_id": "alice",
"scheduler_thread_id": "thread-alice",
"scheduled_for": scheduled_for,
"status": "succeeded",
"started_at": scheduled_for,
}
)
assert run is not None
# Bob (subscriber) sees runs on the task.
bob_runs = await service.list_runs("bob", "t1", limit=10)
assert [r["id"] for r in bob_runs] == ["run-1"]
# Bob can fetch the run by id.
fetched = await service.get_run("bob", "run-1")
assert fetched is not None and fetched["id"] == "run-1"
# Carol (no subscription) cannot.
assert await service.list_runs("carol", "t1", limit=10) == []
assert await service.get_run("carol", "run-1") is None
@pytest.mark.asyncio
async def test_unsubscribe_removes_visibility(store):
_seed_task(store, task_id="t1", user_id="alice", published=True)
service = _build_service(store)
await service.subscribe_task("bob", "t1", notify_element=False)
assert await service.unsubscribe_task("bob", "t1") is True
bob_tasks = await service.list_tasks("bob")
assert all(t["task_id"] != "t1" for t in bob_tasks)
@pytest.mark.asyncio
async def test_mirror_element_delivery_to_subscribers(store):
_seed_task(store, task_id="t1", user_id="alice", published=True)
service = _build_service(store)
# Author and two subscribers configure delivery profiles.
await store.update_delivery_profile("alice", {"element_user_id": "@alice:example"})
await store.update_delivery_profile("bob", {"element_user_id": "@bob:example"})
await store.update_delivery_profile("carol", {"element_user_id": "@carol:example"})
await service.subscribe_task("bob", "t1", notify_element=True)
await service.subscribe_task("carol", "t1", notify_element=False) # opted out
sent: list[tuple[str, str]] = []
class _FakeElement:
async def send_direct_message(self, user_id: str, body: str, room_id: str | None = None) -> str:
sent.append((user_id, body))
return f"!room-{user_id}"
service._element_client = _FakeElement() # noqa: SLF001
task = await store.get_task_any("t1")
assert task is not None
await service._deliver_element_result(task, "result body") # noqa: SLF001
recipients = sorted(user for user, _ in sent)
assert recipients == ["@alice:example", "@bob:example"], "only author + opted-in subscriber receive"
# All recipients get the same body content (we sent identical messages).
bodies = {body for _, body in sent}
assert bodies == {"【定时任务】demo\n\nresult body"}
# Carol is not in the recipient list.
assert all(uid != "@carol:example" for uid, _ in sent)
@pytest.mark.asyncio
async def test_published_summary_truncates_long_prompt(store):
long_prompt = "abcdef" * 100 # 600 chars
_seed_task(store, task_id="t1", user_id="alice", prompt=long_prompt, published=True)
service = _build_service(store)
listed = await service.list_published()
summary = listed[0]["prompt_summary"]
assert len(summary) <= 201 # 200 chars + ellipsis
assert summary.endswith("…")