定时任务订阅模式
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 { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
PublishedScheduledTask,
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
ScheduledTaskDeliveryProfile,
|
ScheduledTaskDeliveryProfile,
|
||||||
ScheduledTaskResultFileContent,
|
ScheduledTaskResultFileContent,
|
||||||
ScheduledTaskRun,
|
ScheduledTaskRun,
|
||||||
|
ScheduledTaskSubscription,
|
||||||
SchedulerThread,
|
SchedulerThread,
|
||||||
} from "./types";
|
} 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,
|
getSchedulerThread,
|
||||||
getScheduledTaskDeliveryProfile,
|
getScheduledTaskDeliveryProfile,
|
||||||
getScheduledTaskRun,
|
getScheduledTaskRun,
|
||||||
|
listPublishedScheduledTasks,
|
||||||
listScheduledTaskRuns,
|
listScheduledTaskRuns,
|
||||||
listScheduledTasks,
|
listScheduledTasks,
|
||||||
pauseScheduledTask,
|
pauseScheduledTask,
|
||||||
|
publishScheduledTask,
|
||||||
resumeScheduledTask,
|
resumeScheduledTask,
|
||||||
|
subscribeScheduledTask,
|
||||||
triggerScheduledTask,
|
triggerScheduledTask,
|
||||||
|
unpublishScheduledTask,
|
||||||
|
unsubscribeScheduledTask,
|
||||||
updateScheduledTaskDeliveryProfile,
|
updateScheduledTaskDeliveryProfile,
|
||||||
updateScheduledTask,
|
updateScheduledTask,
|
||||||
|
updateScheduledTaskSubscription,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
|
|
||||||
export function useSchedulerThread() {
|
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() {
|
export function useScheduledTaskActions() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const invalidate = () =>
|
const invalidate = () =>
|
||||||
@@ -107,5 +122,27 @@ export function useScheduledTaskActions() {
|
|||||||
queryKey: ["scheduled-tasks", "delivery-profile"],
|
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 = {
|
export type ScheduledTask = {
|
||||||
task_id: string;
|
task_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -12,6 +23,20 @@ export type ScheduledTask = {
|
|||||||
source_agent_name?: string | null;
|
source_agent_name?: string | null;
|
||||||
execution_agent_name?: string | null;
|
execution_agent_name?: string | null;
|
||||||
scheduler_thread_id: string;
|
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 = {
|
export type SchedulerThread = {
|
||||||
|
|||||||
@@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
BellOff,
|
||||||
|
BellRing,
|
||||||
Download,
|
Download,
|
||||||
Edit3,
|
Edit3,
|
||||||
Eye,
|
Eye,
|
||||||
|
Globe,
|
||||||
|
Heart,
|
||||||
|
HeartOff,
|
||||||
|
Lock,
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -21,6 +27,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
@@ -30,11 +38,13 @@ import {
|
|||||||
import { useAgents } from "@/core/agents";
|
import { useAgents } from "@/core/agents";
|
||||||
import {
|
import {
|
||||||
downloadScheduledTaskRun,
|
downloadScheduledTaskRun,
|
||||||
|
type PublishedScheduledTask,
|
||||||
type ScheduledTask,
|
type ScheduledTask,
|
||||||
useScheduledTaskActions,
|
useScheduledTaskActions,
|
||||||
useScheduledTaskDeliveryProfile,
|
useScheduledTaskDeliveryProfile,
|
||||||
useScheduledTaskRuns,
|
useScheduledTaskRuns,
|
||||||
useScheduledTasks,
|
useScheduledTasks,
|
||||||
|
usePublishedScheduledTasks,
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -217,6 +227,9 @@ export default function ScheduledTasksPage() {
|
|||||||
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
|
||||||
const { agents } = useAgents();
|
const { agents } = useAgents();
|
||||||
const actions = useScheduledTaskActions();
|
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 [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
const [elementUserId, setElementUserId] = useState("");
|
const [elementUserId, setElementUserId] = useState("");
|
||||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||||
@@ -224,6 +237,8 @@ export default function ScheduledTasksPage() {
|
|||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
|
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
|
||||||
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
|
||||||
|
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
|
||||||
|
const [subscribeNotify, setSubscribeNotify] = useState(false);
|
||||||
|
|
||||||
const sortedTasks = useMemo(
|
const sortedTasks = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -377,95 +392,232 @@ export default function ScheduledTasksPage() {
|
|||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</CardHeader>
|
||||||
<CardContent className="min-h-0 flex-1 p-0">
|
<CardContent className="min-h-0 flex-1 p-0">
|
||||||
<ScrollArea className="size-full">
|
<ScrollArea className="size-full">
|
||||||
<div className="flex flex-col gap-3 p-4 pt-0">
|
{activeTab === "mine" ? (
|
||||||
{isLoading && (
|
<div className="flex flex-col gap-3 p-4 pt-0">
|
||||||
<div className="text-muted-foreground text-sm">正在加载任务...</div>
|
{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">
|
{!isLoading && sortedTasks.length === 0 && (
|
||||||
还没有定时任务。点击新建创建第一个任务。
|
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||||
</div>
|
还没有定时任务。点击新建创建第一个任务,或去“广场”收藏他人发布的任务。
|
||||||
)}
|
</div>
|
||||||
{sortedTasks.map((task) => (
|
)}
|
||||||
<button
|
{sortedTasks.map((task) => {
|
||||||
key={task.task_id}
|
const isOwner = task.role !== "subscriber";
|
||||||
className={cn(
|
const isSubscriber = task.role === "subscriber";
|
||||||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
const notifyOn = !!task._subscription?.notify_element;
|
||||||
selectedTaskId === task.task_id && "border-primary bg-accent",
|
return (
|
||||||
)}
|
<button
|
||||||
onClick={() => setSelectedTaskId(task.task_id)}
|
key={task.task_id}
|
||||||
>
|
className={cn(
|
||||||
<div className="flex items-start justify-between gap-3">
|
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
||||||
<div className="min-w-0">
|
selectedTaskId === task.task_id && "border-primary bg-accent",
|
||||||
<div className="truncate font-medium">{task.name}</div>
|
)}
|
||||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
onClick={() => setSelectedTaskId(task.task_id)}
|
||||||
{task.schedule_text} · {task.timezone}
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={task.enabled ? "default" : "secondary"}>
|
);
|
||||||
{task.enabled ? "启用" : "暂停"}
|
})}
|
||||||
</Badge>
|
</div>
|
||||||
</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>
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -566,6 +718,75 @@ export default function ScheduledTasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
</WorkspaceBody>
|
</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()}>
|
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ class ScheduledTaskDeliveryProfileUpdateRequest(BaseModel):
|
|||||||
element_user_id: str | None = None
|
element_user_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduledTaskSubscribeRequest(BaseModel):
|
||||||
|
notify_element: bool = False
|
||||||
|
|
||||||
|
|
||||||
async def _user_id(request: Request) -> str:
|
async def _user_id(request: Request) -> str:
|
||||||
user_id = await get_current_user(request)
|
user_id = await get_current_user(request)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -325,3 +329,63 @@ async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
|
|||||||
if task is None:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||||
return {"success": True, "task": task}
|
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.feedback.model import FeedbackRow
|
||||||
from deerflow.persistence.models.run_event import RunEventRow
|
from deerflow.persistence.models.run_event import RunEventRow
|
||||||
from deerflow.persistence.run.model import RunRow
|
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.thread_meta.model import ThreadMetaRow
|
||||||
from deerflow.persistence.user.model import UserRow
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
|
||||||
@@ -31,6 +37,7 @@ __all__ = [
|
|||||||
"ScheduledTaskDeliveryProfileRow",
|
"ScheduledTaskDeliveryProfileRow",
|
||||||
"ScheduledTaskRow",
|
"ScheduledTaskRow",
|
||||||
"ScheduledTaskRunRow",
|
"ScheduledTaskRunRow",
|
||||||
|
"ScheduledTaskSubscriptionRow",
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
"UserRow",
|
"UserRow",
|
||||||
|
|||||||
+8
-1
@@ -4,7 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
||||||
from deerflow.persistence.scheduled_tasks.memory import MemoryScheduledTaskStore
|
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
|
from deerflow.persistence.scheduled_tasks.sql import ScheduledTaskRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -14,6 +20,7 @@ __all__ = [
|
|||||||
"ScheduledTaskRow",
|
"ScheduledTaskRow",
|
||||||
"ScheduledTaskRunRow",
|
"ScheduledTaskRunRow",
|
||||||
"ScheduledTaskStore",
|
"ScheduledTaskStore",
|
||||||
|
"ScheduledTaskSubscriptionRow",
|
||||||
"SchedulerThreadRow",
|
"SchedulerThreadRow",
|
||||||
"make_scheduled_task_store",
|
"make_scheduled_task_store",
|
||||||
]
|
]
|
||||||
|
|||||||
+43
@@ -63,3 +63,46 @@ class ScheduledTaskStore(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
raise NotImplementedError
|
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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
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_by_key: set[tuple[str, str]] = set()
|
||||||
self._runs: dict[str, dict[str, Any]] = {}
|
self._runs: dict[str, dict[str, Any]] = {}
|
||||||
self._profiles: 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:
|
async def get_or_create_scheduler_thread(self, user_id: str, thread_id_factory) -> dict:
|
||||||
if user_id not in self._threads:
|
if user_id not in self._threads:
|
||||||
@@ -105,3 +107,73 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
|
|||||||
if "element_room_id" in data:
|
if "element_room_id" in data:
|
||||||
profile["element_room_id"] = data["element_room_id"] or None
|
profile["element_room_id"] = data["element_room_id"] or None
|
||||||
return dict(profile)
|
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_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_status: Mapped[str | None] = mapped_column(String(32))
|
last_status: Mapped[str | None] = mapped_column(String(32))
|
||||||
execution_context_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
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))
|
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))
|
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 datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from deerflow.persistence.scheduled_tasks.base import ScheduledTaskStore
|
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]:
|
def _to_dict(row) -> dict[str, Any]:
|
||||||
data = row.to_dict()
|
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)
|
value = data.get(key)
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
data[key] = value.isoformat()
|
data[key] = value.isoformat()
|
||||||
@@ -194,3 +202,107 @@ class ScheduledTaskRepository(ScheduledTaskStore):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return _to_dict(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
|
return thread
|
||||||
|
|
||||||
async def list_tasks(self, user_id: str) -> list[dict[str, Any]]:
|
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]]:
|
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)
|
accessible = await self._accessible_task(user_id, task_id)
|
||||||
if task is None:
|
if accessible is None:
|
||||||
return []
|
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:
|
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:
|
async def delete_run(self, user_id: str, run_id: str) -> bool:
|
||||||
deleted = await self.store.delete_run(run_id, user_id)
|
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:
|
async def _deliver_element_result(self, task: dict[str, Any], result_text: str) -> None:
|
||||||
if self._element_client is None:
|
if self._element_client is None:
|
||||||
return
|
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")
|
element_user_id = profile.get("element_user_id")
|
||||||
if not isinstance(element_user_id, str) or not element_user_id.strip():
|
if not isinstance(element_user_id, str) or not element_user_id.strip():
|
||||||
return
|
return
|
||||||
existing_room_id: str | None = profile.get("element_room_id") or None
|
existing_room_id: str | None = profile.get("element_room_id") or None
|
||||||
body = f"【定时任务】{task['name']}\n\n{result_text}"
|
|
||||||
try:
|
try:
|
||||||
room_id = await self._element_client.send_direct_message(element_user_id.strip(), body, room_id=existing_room_id)
|
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:
|
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:
|
except Exception:
|
||||||
logger.exception("Failed to deliver scheduled task %s to Element user %s", task["task_id"], element_user_id)
|
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