feat: 定时任务运行详情执行过程视图 + 管理员下载删除 + MySQL 迁移修复
定时任务:
- 运行详情页新增「执行过程」标签页,按 agent_run_id 拉取该次运行的
agent 消息(GET /scheduled-tasks/runs/{id}/messages),运行中每 4s
自动刷新,可观察卡住的任务停在哪一步
- 详情页「返回」按钮回到来源列表(普通/管理员)并重新选中原任务
- 管理员可下载、删除任意用户的执行记录(DELETE /runs/{id} 加管理员兜底)
MySQL 兼容修复:
- 合并迁移 20260518_06 汇合 ai-writing 与 thread-shares 两个 head
- 20260517_02 改用 SQLAlchemy Core 构造,规避保留字 key 的 1064 报错
- list_messages_by_run 各事件存储补 user_id 形参以保持接口一致
测试: 新增 test_scheduled_task_admin_run_delete.py
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
449703a6f0
commit
e3cde9be25
18 files changed
+534
-57
No files matched your search
@@ -8,6 +8,7 @@ import type {
|
|||||||
ScheduledTaskResultFileContent,
|
ScheduledTaskResultFileContent,
|
||||||
ScheduledTaskRun,
|
ScheduledTaskRun,
|
||||||
ScheduledTaskRunFileUpdateResponse,
|
ScheduledTaskRunFileUpdateResponse,
|
||||||
|
ScheduledTaskRunMessagesResponse,
|
||||||
ScheduledTaskSubscription,
|
ScheduledTaskSubscription,
|
||||||
SchedulerThread,
|
SchedulerThread,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
@@ -79,6 +80,13 @@ export async function deleteScheduledTaskRun(runId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Agent messages for a run's underlying agent run — used to inspect a stuck/running run. */
|
||||||
|
export async function getScheduledTaskRunMessages(runId: string) {
|
||||||
|
return parseResponse<ScheduledTaskRunMessagesResponse>(
|
||||||
|
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/messages`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function scheduledTaskRunDownloadUrl(runId: string) {
|
export function scheduledTaskRunDownloadUrl(runId: string) {
|
||||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getSchedulerThread,
|
getSchedulerThread,
|
||||||
getScheduledTaskDeliveryProfile,
|
getScheduledTaskDeliveryProfile,
|
||||||
getScheduledTaskRun,
|
getScheduledTaskRun,
|
||||||
|
getScheduledTaskRunMessages,
|
||||||
listAllScheduledTasks,
|
listAllScheduledTasks,
|
||||||
listPublishedScheduledTasks,
|
listPublishedScheduledTasks,
|
||||||
listScheduledTaskRuns,
|
listScheduledTaskRuns,
|
||||||
@@ -62,12 +63,34 @@ export function useScheduledTaskRuns(taskId?: string | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A run still in flight — used to drive live polling of the run/messages. */
|
||||||
|
function runIsLive(status?: string | null): boolean {
|
||||||
|
return status === "running" || status === "pending";
|
||||||
|
}
|
||||||
|
|
||||||
export function useScheduledTaskRun(runId?: string | null) {
|
export function useScheduledTaskRun(runId?: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheduled-tasks", "runs", runId],
|
queryKey: ["scheduled-tasks", "runs", runId],
|
||||||
queryFn: () => getScheduledTaskRun(runId!),
|
queryFn: () => getScheduledTaskRun(runId!),
|
||||||
enabled: !!runId,
|
enabled: !!runId,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
// Keep refreshing while the run is still executing so the detail page
|
||||||
|
// reflects status / result changes without a manual reload.
|
||||||
|
refetchInterval: (query) => (runIsLive(query.state.data?.status) ? 5000 : false),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent messages for a run; polls every 4s while the run is still executing. */
|
||||||
|
export function useScheduledTaskRunMessages(
|
||||||
|
runId?: string | null,
|
||||||
|
options: { live?: boolean } = {},
|
||||||
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["scheduled-tasks", "runs", runId, "messages"],
|
||||||
|
queryFn: () => getScheduledTaskRunMessages(runId!),
|
||||||
|
enabled: !!runId,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchInterval: options.live ? 4000 : false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +147,12 @@ export function useAdminScheduledTaskActions() {
|
|||||||
mutationFn: adminDeleteScheduledTask,
|
mutationFn: adminDeleteScheduledTask,
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
}),
|
}),
|
||||||
|
// The DELETE /runs/{id} route is admin-aware, so the same call deletes
|
||||||
|
// any user's run when the caller is an admin.
|
||||||
|
removeRun: useMutation({
|
||||||
|
mutationFn: deleteScheduledTaskRun,
|
||||||
|
onSuccess: invalidate,
|
||||||
|
}),
|
||||||
trigger: useMutation({
|
trigger: useMutation({
|
||||||
mutationFn: adminTriggerScheduledTask,
|
mutationFn: adminTriggerScheduledTask,
|
||||||
onSuccess: invalidate,
|
onSuccess: invalidate,
|
||||||
|
|||||||
@@ -107,3 +107,19 @@ export type ScheduledTaskRunFileUpdateResponse = {
|
|||||||
mime_type?: string | null;
|
mime_type?: string | null;
|
||||||
kind?: string | null;
|
kind?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** One agent message produced by a scheduled run's underlying agent run. */
|
||||||
|
export type ScheduledTaskRunMessage = {
|
||||||
|
seq: number;
|
||||||
|
event_type: string;
|
||||||
|
category: string;
|
||||||
|
// string for human input; object (LangChain message dump) for ai/tool.
|
||||||
|
content: unknown;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScheduledTaskRunMessagesResponse = {
|
||||||
|
messages: ScheduledTaskRunMessage[];
|
||||||
|
has_more: boolean;
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
Download,
|
||||||
Edit3,
|
Edit3,
|
||||||
Eye,
|
Eye,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ import {
|
|||||||
statusVariant,
|
statusVariant,
|
||||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||||
import {
|
import {
|
||||||
|
downloadScheduledTaskRun,
|
||||||
type ScheduledTask,
|
type ScheduledTask,
|
||||||
useAdminScheduledTaskActions,
|
useAdminScheduledTaskActions,
|
||||||
useAdminScheduledTaskRuns,
|
useAdminScheduledTaskRuns,
|
||||||
@@ -68,12 +71,19 @@ const emptyForm = {
|
|||||||
|
|
||||||
export default function AdminScheduledTasksPage() {
|
export default function AdminScheduledTasksPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
// ?task= carries the task to re-select when returning from a run detail.
|
||||||
|
const initialTaskId = searchParams.get("task");
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { data: tasks = [], isLoading } = useAllScheduledTasks({ search });
|
const { data: tasks = [], isLoading } = useAllScheduledTasks({ search });
|
||||||
const actions = useAdminScheduledTaskActions();
|
const actions = useAdminScheduledTaskActions();
|
||||||
|
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
|
||||||
|
initialTaskId,
|
||||||
|
);
|
||||||
|
// One-shot scroll target after a back-navigation.
|
||||||
|
const pendingScrollRef = useRef<string | null>(initialTaskId);
|
||||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(
|
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(
|
||||||
@@ -174,6 +184,24 @@ export default function AdminScheduledTasksPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadRun = async (runId: string) => {
|
||||||
|
try {
|
||||||
|
await downloadScheduledTaskRun(runId);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRun = async (runId: string) => {
|
||||||
|
if (!window.confirm("确认删除这条执行记录?该操作不可撤销。")) return;
|
||||||
|
try {
|
||||||
|
await actions.removeRun.mutateAsync(runId);
|
||||||
|
toast.success("执行记录已删除");
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceContainer>
|
<WorkspaceContainer>
|
||||||
<WorkspaceHeader />
|
<WorkspaceHeader />
|
||||||
@@ -221,6 +249,12 @@ export default function AdminScheduledTasksPage() {
|
|||||||
{sortedTasks.map((task) => (
|
{sortedTasks.map((task) => (
|
||||||
<button
|
<button
|
||||||
key={task.task_id}
|
key={task.task_id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && pendingScrollRef.current === task.task_id) {
|
||||||
|
pendingScrollRef.current = null;
|
||||||
|
el.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
||||||
selectedTaskId === task.task_id &&
|
selectedTaskId === task.task_id &&
|
||||||
@@ -371,13 +405,30 @@ export default function AdminScheduledTasksPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(
|
navigate(
|
||||||
`/page/workspace/scheduled/runs/${run.id}`,
|
`/page/workspace/scheduled/runs/${run.id}?from=admin&task=${encodeURIComponent(run.task_id)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
查看结果
|
查看结果
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!run.result}
|
||||||
|
onClick={() => void downloadRun(run.id)}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
下载结果
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void deleteRun(run.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
|
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -24,9 +24,11 @@ import {
|
|||||||
getScheduledTaskRunFileContent,
|
getScheduledTaskRunFileContent,
|
||||||
type ScheduledTaskResultFileContent,
|
type ScheduledTaskResultFileContent,
|
||||||
type ScheduledTaskRun,
|
type ScheduledTaskRun,
|
||||||
|
type ScheduledTaskRunMessage,
|
||||||
updateScheduledTaskRunFile,
|
updateScheduledTaskRunFile,
|
||||||
useScheduledTaskActions,
|
useScheduledTaskActions,
|
||||||
useScheduledTaskRun,
|
useScheduledTaskRun,
|
||||||
|
useScheduledTaskRunMessages,
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -97,12 +99,117 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
|||||||
|
|
||||||
type EditState = { draft: string; saving: boolean };
|
type EditState = { draft: string; saving: boolean };
|
||||||
|
|
||||||
|
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
|
||||||
|
function messageText(content: unknown): string {
|
||||||
|
if (content == null) return "";
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content
|
||||||
|
.map((part) => {
|
||||||
|
if (typeof part === "string") return part;
|
||||||
|
if (part && typeof part === "object" && "text" in part) {
|
||||||
|
return String((part as { text?: unknown }).text ?? "");
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
if (typeof content === "object" && "content" in content) {
|
||||||
|
return messageText((content as { content?: unknown }).content);
|
||||||
|
}
|
||||||
|
return JSON.stringify(content, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXECUTION_ROLE: Record<string, { label: string; tone: string }> = {
|
||||||
|
"llm.human.input": { label: "触发输入", tone: "bg-muted text-foreground" },
|
||||||
|
"llm.ai.response": { label: "智能体", tone: "bg-primary/10 text-primary" },
|
||||||
|
"llm.tool.result": { label: "工具结果", tone: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One agent message rendered inside the execution-process tab. */
|
||||||
|
function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
|
||||||
|
const role = EXECUTION_ROLE[message.event_type] ?? {
|
||||||
|
label: message.event_type,
|
||||||
|
tone: "bg-muted text-foreground",
|
||||||
|
};
|
||||||
|
const raw = message.content;
|
||||||
|
const obj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||||
|
const text = messageText(raw);
|
||||||
|
const toolName = typeof obj?.name === "string" ? obj.name : null;
|
||||||
|
const toolCalls = Array.isArray(obj?.tool_calls)
|
||||||
|
? (obj!.tool_calls as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
const isAi = message.event_type === "llm.ai.response";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||||
|
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
|
||||||
|
{role.label}
|
||||||
|
{toolName ? ` · ${toolName}` : ""}
|
||||||
|
</span>
|
||||||
|
{message.created_at && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{new Date(message.created_at).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{text ? (
|
||||||
|
isAi ? (
|
||||||
|
<MarkdownContent className="max-w-none" content={text} isLoading={false} rehypePlugins={[]} />
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap break-words text-xs">{text}</pre>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
!toolCalls.length && <div className="text-muted-foreground text-xs">(无文本内容)</div>
|
||||||
|
)}
|
||||||
|
{toolCalls.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-col gap-1">
|
||||||
|
{toolCalls.map((call, index) => (
|
||||||
|
<div key={index} className="bg-muted rounded px-2 py-1 text-xs">
|
||||||
|
<span className="font-medium">调用工具:{String(call.name ?? "未知")}</span>
|
||||||
|
{call.args != null && (
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap break-words">
|
||||||
|
{typeof call.args === "string" ? call.args : JSON.stringify(call.args, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ScheduledTaskRunDetailPage() {
|
export default function ScheduledTaskRunDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { run_id } = useParams();
|
const { run_id } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const actions = useScheduledTaskActions();
|
const actions = useScheduledTaskActions();
|
||||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||||
const result = run?.result ?? null;
|
const result = run?.result ?? null;
|
||||||
|
// A run still executing — drives live polling of its agent messages.
|
||||||
|
const runLive = run?.status === "running" || run?.status === "pending";
|
||||||
|
const hasExecution = !!run?.agent_run_id;
|
||||||
|
const { data: messagesData, isLoading: messagesLoading } = useScheduledTaskRunMessages(
|
||||||
|
hasExecution ? run_id : null,
|
||||||
|
{ live: runLive },
|
||||||
|
);
|
||||||
|
const runMessages = messagesData?.messages ?? [];
|
||||||
|
|
||||||
|
// Back navigation returns to the originating list (admin vs. mine) and
|
||||||
|
// re-selects the task the user came from (carried in ?task=).
|
||||||
|
const handleBack = useCallback(() => {
|
||||||
|
const base =
|
||||||
|
searchParams.get("from") === "admin"
|
||||||
|
? "/page/workspace/admin/scheduled-tasks"
|
||||||
|
: "/page/workspace/scheduled/tasks";
|
||||||
|
const taskId = run?.task_id ?? searchParams.get("task");
|
||||||
|
navigate(taskId ? `${base}?task=${encodeURIComponent(taskId)}` : base);
|
||||||
|
}, [navigate, run?.task_id, searchParams]);
|
||||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||||
const [filesLoading, setFilesLoading] = useState(false);
|
const [filesLoading, setFilesLoading] = useState(false);
|
||||||
const [showMeta, setShowMeta] = useState(false);
|
const [showMeta, setShowMeta] = useState(false);
|
||||||
@@ -257,7 +364,7 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await actions.removeRun.mutateAsync(run.id);
|
await actions.removeRun.mutateAsync(run.id);
|
||||||
toast.success("执行记录已删除");
|
toast.success("执行记录已删除");
|
||||||
navigate("/page/workspace/scheduled/tasks");
|
handleBack();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||||
}
|
}
|
||||||
@@ -281,9 +388,9 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||||
执行信息
|
执行信息
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleBack}>
|
||||||
<ListChecks className="size-3" />
|
<ChevronLeft className="size-3" />
|
||||||
返回记录
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||||
<Download className="size-3" />
|
<Download className="size-3" />
|
||||||
@@ -330,28 +437,45 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
{!result && run && (
|
{run && !result && !hasExecution && (
|
||||||
<div className="text-muted-foreground p-4 text-sm">
|
<div className="text-muted-foreground p-4 text-sm">
|
||||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && (
|
{run && (result || hasExecution) && (
|
||||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
<Tabs
|
||||||
|
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
|
||||||
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
>
|
||||||
<div className="border-b px-3 pt-1">
|
<div className="border-b px-3 pt-1">
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="body" className="text-xs">
|
{result && (
|
||||||
<FileText className="mr-1 size-3" />
|
<TabsTrigger value="body" className="text-xs">
|
||||||
正文
|
<FileText className="mr-1 size-3" />
|
||||||
</TabsTrigger>
|
正文
|
||||||
<TabsTrigger value="files" className="text-xs">
|
</TabsTrigger>
|
||||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
)}
|
||||||
</TabsTrigger>
|
{result && (
|
||||||
<TabsTrigger value="meta" className="text-xs">
|
<TabsTrigger value="files" className="text-xs">
|
||||||
元数据
|
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{hasExecution && (
|
||||||
|
<TabsTrigger value="execution" className="text-xs">
|
||||||
|
<Activity className="mr-1 size-3" />
|
||||||
|
执行过程{runMessages.length > 0 && `(${runMessages.length})`}
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<TabsTrigger value="meta" className="text-xs">
|
||||||
|
元数据
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
<TabsContent value="body" className="m-0 p-4">
|
<TabsContent value="body" className="m-0 p-4">
|
||||||
{result.format === "markdown" || result.format === "mixed" ? (
|
{result.format === "markdown" || result.format === "mixed" ? (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
@@ -471,6 +595,31 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
{resultMetadata}
|
{resultMetadata}
|
||||||
</pre>
|
</pre>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasExecution && (
|
||||||
|
<TabsContent value="execution" className="m-0 p-3">
|
||||||
|
{runLive && (
|
||||||
|
<div className="text-primary border-primary/30 bg-primary/5 mb-3 flex items-center gap-2 rounded-md border p-2 text-xs">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
任务执行中,下方消息每 4 秒自动刷新…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messagesLoading && runMessages.length === 0 && (
|
||||||
|
<div className="text-muted-foreground text-sm">正在加载执行消息...</div>
|
||||||
|
)}
|
||||||
|
{!messagesLoading && runMessages.length === 0 && (
|
||||||
|
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||||
|
暂无执行消息,任务可能还未开始执行。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{runMessages.map((message) => (
|
||||||
|
<ExecutionMessage key={message.seq} message={message} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BellOff,
|
BellOff,
|
||||||
BellRing,
|
BellRing,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
TagsIcon,
|
TagsIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ const emptyForm = {
|
|||||||
|
|
||||||
export default function ScheduledTasksPage() {
|
export default function ScheduledTasksPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
// When returning from a run-detail page the task id is carried in ?task=,
|
||||||
|
// so the list re-selects (and scrolls to) the task the user was viewing.
|
||||||
|
const initialTaskId = searchParams.get("task");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [tagId, setTagId] = useState<string | null>(null);
|
const [tagId, setTagId] = useState<string | null>(null);
|
||||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
||||||
@@ -94,7 +99,9 @@ export default function ScheduledTasksPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
|
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
|
||||||
const { data: published = [], isLoading: publishedLoading } =
|
const { data: published = [], isLoading: publishedLoading } =
|
||||||
usePublishedScheduledTasks(activeTab === "plaza");
|
usePublishedScheduledTasks(activeTab === "plaza");
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(initialTaskId);
|
||||||
|
// One-shot scroll target: scroll the task into view after a back-navigation.
|
||||||
|
const pendingScrollRef = useRef<string | null>(initialTaskId);
|
||||||
const [elementUserId, setElementUserId] = useState("");
|
const [elementUserId, setElementUserId] = useState("");
|
||||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -300,6 +307,12 @@ export default function ScheduledTasksPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={task.task_id}
|
key={task.task_id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && pendingScrollRef.current === task.task_id) {
|
||||||
|
pendingScrollRef.current = null;
|
||||||
|
el.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
"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",
|
selectedTaskId === task.task_id && "border-primary bg-accent",
|
||||||
@@ -598,7 +611,11 @@ export default function ScheduledTasksPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/page/workspace/scheduled/runs/${run.id}?from=mine&task=${encodeURIComponent(run.task_id)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Eye className="size-4" />
|
<Eye className="size-4" />
|
||||||
查看结果
|
查看结果
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|
|||||||
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
|
||||||
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
|
||||||
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves an **image** referenced by that conversation (images only — non-image paths 404; SVG forced to download). Missing/revoked/non-`view` codes all return 404. |
|
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves an **image** referenced by that conversation (images only — non-image paths 404; SVG forced to download). Missing/revoked/non-`view` codes all return 404. |
|
||||||
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET/DELETE /runs/{rid}`, run-file preview/edit/download; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
|
||||||
|
|
||||||
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|||||||
from fastapi.responses import FileResponse, Response, StreamingResponse
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_current_user, get_local_provider, get_optional_user_from_request, get_tag_store
|
from app.gateway.deps import get_current_user, get_local_provider, get_optional_user_from_request, get_run_event_store, get_run_store, get_tag_store
|
||||||
from deerflow.runtime.scheduler import DuplicateTaskNameError, get_scheduled_task_service
|
from deerflow.runtime.scheduler import DuplicateTaskNameError, get_scheduled_task_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||||
@@ -234,6 +234,42 @@ async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
|
|||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}/messages")
|
||||||
|
async def get_scheduled_task_run_messages(run_id: str, request: Request, limit: int = 500) -> dict:
|
||||||
|
"""Agent messages produced by a scheduled run's underlying agent run.
|
||||||
|
|
||||||
|
Lets the run-detail page show what the agent is doing — useful when a run
|
||||||
|
is stuck in ``running``. Authorization reuses :func:`_load_run` (owner /
|
||||||
|
subscriber / admin); the event-store query then passes ``user_id=None``
|
||||||
|
because access has already been checked.
|
||||||
|
"""
|
||||||
|
service = get_scheduled_task_service()
|
||||||
|
user_id = await _user_id(request)
|
||||||
|
run = await _load_run(service, request, run_id, user_id)
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
|
agent_run_id = run.get("agent_run_id")
|
||||||
|
if not agent_run_id:
|
||||||
|
return {"messages": [], "has_more": False}
|
||||||
|
# Each scheduled run executes on its own throwaway runtime thread — NOT the
|
||||||
|
# run row's ``scheduler_thread_id`` — so resolve the real thread from the
|
||||||
|
# run store before querying its message events.
|
||||||
|
run_store = get_run_store(request)
|
||||||
|
agent_run = await run_store.get(agent_run_id, user_id=None)
|
||||||
|
if agent_run is None:
|
||||||
|
return {"messages": [], "has_more": False}
|
||||||
|
capped = max(1, min(limit, 1000))
|
||||||
|
event_store = get_run_event_store(request)
|
||||||
|
rows = await event_store.list_messages_by_run(
|
||||||
|
agent_run["thread_id"],
|
||||||
|
agent_run_id,
|
||||||
|
limit=capped + 1,
|
||||||
|
user_id=None,
|
||||||
|
)
|
||||||
|
has_more = len(rows) > capped
|
||||||
|
return {"messages": rows[:capped] if has_more else rows, "has_more": has_more}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/runs/{run_id}")
|
@router.delete("/runs/{run_id}")
|
||||||
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
@@ -241,7 +277,15 @@ async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
|||||||
try:
|
try:
|
||||||
deleted = await service.delete_run(user_id, run_id)
|
deleted = await service.delete_run(user_id, run_id)
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
raise HTTPException(status_code=403, detail=str(exc))
|
# Owner-only for regular users; admins may still delete any run.
|
||||||
|
if await _is_admin(request):
|
||||||
|
deleted = await service.admin_delete_run(run_id)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail=str(exc))
|
||||||
|
# A run the caller does not own returns deleted=False — fall back to an
|
||||||
|
# admin-scoped delete so admins can clean up other users' runs.
|
||||||
|
if not deleted and await _is_admin(request):
|
||||||
|
deleted = await service.admin_delete_run(run_id)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|||||||
+28
-18
@@ -76,29 +76,39 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 预置默认文章类型(幂等,跳过已存在的 key)
|
# 预置默认文章类型(幂等,跳过已存在的 key)。
|
||||||
|
# 用 SQLAlchemy Core 构造而非裸 SQL:``key`` 是 MySQL 保留字,裸
|
||||||
|
# ``SELECT key`` 会触发 1064 语法错误。Core 编译器会按方言自动给保留字
|
||||||
|
# 加引号(MySQL 反引号 / Postgres 双引号),保持跨后端可移植。
|
||||||
|
article_types = sa.table(
|
||||||
|
"ai_writing_article_types",
|
||||||
|
sa.column("id", sa.String),
|
||||||
|
sa.column("key", sa.String),
|
||||||
|
sa.column("label", sa.String),
|
||||||
|
sa.column("description", sa.Text),
|
||||||
|
sa.column("prompt_hint", sa.Text),
|
||||||
|
sa.column("default_word_count", sa.Integer),
|
||||||
|
sa.column("sort_order", sa.Integer),
|
||||||
|
sa.column("created_at", sa.DateTime),
|
||||||
|
sa.column("updated_at", sa.DateTime),
|
||||||
|
)
|
||||||
conn = op.get_bind()
|
conn = op.get_bind()
|
||||||
existing_keys = {row[0] for row in conn.execute(sa.text("SELECT key FROM ai_writing_article_types")).fetchall()}
|
existing_keys = {row[0] for row in conn.execute(sa.select(article_types.c.key)).fetchall()}
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
for t in _DEFAULT_TYPES:
|
for t in _DEFAULT_TYPES:
|
||||||
if t["key"] not in existing_keys:
|
if t["key"] not in existing_keys:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.text(
|
article_types.insert().values(
|
||||||
"INSERT INTO ai_writing_article_types "
|
id=str(uuid.uuid4()),
|
||||||
"(id, key, label, description, prompt_hint, default_word_count, sort_order, created_at, updated_at) "
|
key=t["key"],
|
||||||
"VALUES (:id, :key, :label, :description, :prompt_hint, :default_word_count, :sort_order, :created_at, :updated_at)"
|
label=t["label"],
|
||||||
),
|
description=t["description"],
|
||||||
{
|
prompt_hint=t["prompt_hint"],
|
||||||
"id": str(uuid.uuid4()),
|
default_word_count=t["default_word_count"],
|
||||||
"key": t["key"],
|
sort_order=t["sort_order"],
|
||||||
"label": t["label"],
|
created_at=now,
|
||||||
"description": t["description"],
|
updated_at=now,
|
||||||
"prompt_hint": t["prompt_hint"],
|
)
|
||||||
"default_word_count": t["default_word_count"],
|
|
||||||
"sort_order": t["sort_order"],
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
"""Merge the ai-writing and thread-shares migration heads.
|
||||||
|
|
||||||
|
Two independent migration branches diverged from ``20260515_01``: the AI
|
||||||
|
writing feature (``20260517_01..03``) and the thread-shares / scheduled-task
|
||||||
|
work (``20260518_01..05``). Merging the two branches into one repo left Alembic
|
||||||
|
with multiple heads, so ``upgrade head`` failed. This is a no-op merge revision
|
||||||
|
that joins both lineages — it changes no schema.
|
||||||
|
|
||||||
|
Revision ID: 20260518_06
|
||||||
|
Revises: 20260517_03, 20260518_05
|
||||||
|
Create Date: 2026-05-18
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260518_06"
|
||||||
|
down_revision = ("20260517_03", "20260518_05")
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
+5
@@ -85,6 +85,11 @@ class ScheduledTaskStore(ABC):
|
|||||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_run_any(self, run_id: str) -> bool:
|
||||||
|
"""Delete a run by id without restricting by owner. Admin-only callers."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
+12
-2
@@ -121,11 +121,21 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
|
|||||||
run = self._runs.get(run_id)
|
run = self._runs.get(run_id)
|
||||||
if run is None or run["user_id"] != user_id:
|
if run is None or run["user_id"] != user_id:
|
||||||
return False
|
return False
|
||||||
self._runs.pop(run_id, None)
|
self._drop_run(run)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_run_any(self, run_id: str) -> bool:
|
||||||
|
run = self._runs.get(run_id)
|
||||||
|
if run is None:
|
||||||
|
return False
|
||||||
|
self._drop_run(run)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _drop_run(self, run: dict[str, Any]) -> None:
|
||||||
|
self._runs.pop(run["id"], None)
|
||||||
scheduled_for = run.get("scheduled_for")
|
scheduled_for = run.get("scheduled_for")
|
||||||
if hasattr(scheduled_for, "isoformat"):
|
if hasattr(scheduled_for, "isoformat"):
|
||||||
self._runs_by_key.discard((run["task_id"], scheduled_for.isoformat()))
|
self._runs_by_key.discard((run["task_id"], scheduled_for.isoformat()))
|
||||||
return True
|
|
||||||
|
|
||||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||||
return dict(self._profiles.get(user_id) or {"user_id": user_id, "element_user_id": None})
|
return dict(self._profiles.get(user_id) or {"user_id": user_id, "element_user_id": None})
|
||||||
|
|||||||
+17
-1
@@ -234,6 +234,15 @@ class ScheduledTaskRepository(ScheduledTaskStore):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def delete_run_any(self, run_id: str) -> bool:
|
||||||
|
async with self._sf() as session:
|
||||||
|
row = await session.get(ScheduledTaskRunRow, run_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
||||||
@@ -286,7 +295,14 @@ class ScheduledTaskRepository(ScheduledTaskStore):
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ScheduledTaskRow)
|
select(ScheduledTaskRow)
|
||||||
.where(ScheduledTaskRow.published.is_(True))
|
.where(ScheduledTaskRow.published.is_(True))
|
||||||
.order_by(ScheduledTaskRow.published_at.desc().nullslast(), ScheduledTaskRow.created_at.desc())
|
# ``NULLS LAST`` is unsupported by MySQL — sort by an
|
||||||
|
# ``IS NULL`` flag instead (False<True ⇒ non-null rows first),
|
||||||
|
# which is portable across mysql/sqlite/postgres.
|
||||||
|
.order_by(
|
||||||
|
ScheduledTaskRow.published_at.is_(None),
|
||||||
|
ScheduledTaskRow.published_at.desc(),
|
||||||
|
ScheduledTaskRow.created_at.desc(),
|
||||||
|
)
|
||||||
.offset(max(0, offset))
|
.offset(max(0, offset))
|
||||||
.limit(max(1, limit))
|
.limit(max(1, limit))
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -87,6 +87,7 @@ class RunEventStore(abc.ABC):
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
before_seq: int | None = None,
|
before_seq: int | None = None,
|
||||||
after_seq: int | None = None,
|
after_seq: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return displayable messages (category=message) for a specific run, ordered by seq ascending.
|
"""Return displayable messages (category=message) for a specific run, ordered by seq ascending.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -152,7 +152,9 @@ class JsonlRunEventStore(RunEventStore):
|
|||||||
events = [e for e in events if e.get("event_type") in event_types]
|
events = [e for e in events if e.get("event_type") in event_types]
|
||||||
return events[:limit]
|
return events[:limit]
|
||||||
|
|
||||||
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
|
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id=None):
|
||||||
|
# JSONL events carry no per-event owner; user_id is accepted for
|
||||||
|
# interface parity with DbRunEventStore but ignored.
|
||||||
events = self._read_run_events(thread_id, run_id)
|
events = self._read_run_events(thread_id, run_id)
|
||||||
filtered = [e for e in events if e.get("category") == "message"]
|
filtered = [e for e in events if e.get("category") == "message"]
|
||||||
if before_seq is not None:
|
if before_seq is not None:
|
||||||
|
|||||||
+3
-1
@@ -97,7 +97,9 @@ class MemoryRunEventStore(RunEventStore):
|
|||||||
filtered = [e for e in filtered if e["event_type"] in event_types]
|
filtered = [e for e in filtered if e["event_type"] in event_types]
|
||||||
return filtered[:limit]
|
return filtered[:limit]
|
||||||
|
|
||||||
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
|
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id=None):
|
||||||
|
# The in-memory store keeps no per-event owner, so user_id is accepted
|
||||||
|
# for interface parity with DbRunEventStore but ignored.
|
||||||
all_events = self._events.get(thread_id, [])
|
all_events = self._events.get(thread_id, [])
|
||||||
filtered = [e for e in all_events if e["run_id"] == run_id and e["category"] == "message"]
|
filtered = [e for e in all_events if e["run_id"] == run_id and e["category"] == "message"]
|
||||||
if before_seq is not None:
|
if before_seq is not None:
|
||||||
|
|||||||
+18
-6
@@ -358,15 +358,20 @@ class ScheduledTaskService:
|
|||||||
"kind": item.get("kind"),
|
"kind": item.get("kind"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_run_archive(run_id: str) -> None:
|
||||||
|
"""Best-effort cleanup of a run's on-disk result archive."""
|
||||||
|
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id
|
||||||
|
if archive_dir.exists():
|
||||||
|
try:
|
||||||
|
shutil.rmtree(archive_dir)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
|
||||||
|
|
||||||
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)
|
||||||
if deleted:
|
if deleted:
|
||||||
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id
|
self._remove_run_archive(run_id)
|
||||||
if archive_dir.exists():
|
|
||||||
try:
|
|
||||||
shutil.rmtree(archive_dir)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
|
|
||||||
return True
|
return True
|
||||||
candidate = await self.store.get_run_any(run_id)
|
candidate = await self.store.get_run_any(run_id)
|
||||||
if candidate is not None and await self.store.get_subscription(candidate["task_id"], user_id) is not None:
|
if candidate is not None and await self.store.get_subscription(candidate["task_id"], user_id) is not None:
|
||||||
@@ -506,6 +511,13 @@ class ScheduledTaskService:
|
|||||||
"""Delete any task regardless of owner. Admin-only callers."""
|
"""Delete any task regardless of owner. Admin-only callers."""
|
||||||
return await self.store.delete_task_any(task_id)
|
return await self.store.delete_task_any(task_id)
|
||||||
|
|
||||||
|
async def admin_delete_run(self, run_id: str) -> bool:
|
||||||
|
"""Delete a single run regardless of owner. Admin-only callers."""
|
||||||
|
deleted = await self.store.delete_run_any(run_id)
|
||||||
|
if deleted:
|
||||||
|
self._remove_run_archive(run_id)
|
||||||
|
return deleted
|
||||||
|
|
||||||
async def admin_list_runs(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
async def admin_list_runs(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
"""List runs for any task regardless of owner. Admin-only callers."""
|
"""List runs for any task regardless of owner. Admin-only callers."""
|
||||||
runs = await self.store.list_runs_any(task_id, limit=limit)
|
runs = await self.store.list_runs_any(task_id, limit=limit)
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Tests for admin (cross-user) deletion of scheduled-task runs.
|
||||||
|
|
||||||
|
Regular users may only delete runs they own; admins may delete any run. This
|
||||||
|
covers the service/store layer that the admin-aware ``DELETE /runs/{id}``
|
||||||
|
route relies on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
|
||||||
|
from deerflow.runtime.scheduler.service import ScheduledTaskService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store() -> MemoryScheduledTaskStore:
|
||||||
|
return MemoryScheduledTaskStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
|
||||||
|
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
|
||||||
|
service._element_client = None # noqa: SLF001
|
||||||
|
return service
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_run(store: MemoryScheduledTaskStore, *, run_id: str, task_id: str, user_id: str) -> None:
|
||||||
|
await store.create_run_once(
|
||||||
|
{
|
||||||
|
"id": run_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"scheduler_thread_id": f"thread-{user_id}",
|
||||||
|
"scheduled_for": datetime.now(UTC),
|
||||||
|
"status": "succeeded",
|
||||||
|
"started_at": datetime.now(UTC),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_delete_run_removes_any_owner_run(store):
|
||||||
|
await _seed_run(store, run_id="r1", task_id="t1", user_id="alice")
|
||||||
|
service = _build_service(store)
|
||||||
|
|
||||||
|
assert await service.admin_delete_run("r1") is True
|
||||||
|
assert await store.get_run_any("r1") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_admin_delete_run_missing_returns_false(store):
|
||||||
|
service = _build_service(store)
|
||||||
|
assert await service.admin_delete_run("nope") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_owner_delete_run_still_scoped_to_owner(store):
|
||||||
|
"""A non-owner, non-subscriber deleting another user's run gets False."""
|
||||||
|
await _seed_run(store, run_id="r1", task_id="t1", user_id="alice")
|
||||||
|
service = _build_service(store)
|
||||||
|
|
||||||
|
assert await service.delete_run("bob", "r1") is False
|
||||||
|
assert await store.get_run_any("r1") is not None # untouched
|
||||||
|
|
||||||
|
# The owner can still delete their own run.
|
||||||
|
assert await service.delete_run("alice", "r1") is True
|
||||||
|
assert await store.get_run_any("r1") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_delete_run_any_independent_of_owner(store):
|
||||||
|
await _seed_run(store, run_id="r1", task_id="t1", user_id="alice")
|
||||||
|
|
||||||
|
assert await store.delete_run_any("r1") is True
|
||||||
|
assert await store.delete_run_any("r1") is False
|
||||||
Reference in new issue
Block a user