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:
吉鑫升andClaude Opus 4.7 committed 2026-05-18 21:13:58 +08:00
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>
)} )}
+20 -3
View File
@@ -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" />
查看结果 查看结果
+1 -1
View File
@@ -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}
@@ -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,
},
) )
@@ -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
@@ -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
@@ -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})
@@ -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))
) )
@@ -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.
@@ -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:
@@ -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:
@@ -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