定时任务订阅模式
This commit is contained in:
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 = {
|
||||
|
||||
@@ -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}
|
||||
+66
@@ -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")
|
||||
+8
-1
@@ -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",
|
||||
|
||||
+8
-1
@@ -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",
|
||||
]
|
||||
|
||||
+43
@@ -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
|
||||
+73
-1
@@ -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")
|
||||
]
|
||||
+14
@@ -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))
|
||||
|
||||
|
||||
+115
-3
@@ -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()]
|
||||
+109
-8
@@ -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("…")
|
||||
Reference in new issue
Block a user