- tool results carry a budget reminder near limits; text before the last tool call is dropped as report when cut by budget/abort - usage persisted when a run suspends, so waiting sessions show spent tokens - task page shows budget_exceeded via business.outcome instead of completed - budget-exceeded decisions merge by root cause (decision / schedule / goal template); new summaries added to investigate allowlist Co-Authored-By: Claude Opus 5.5 <[email protected]>
365 lines
13 KiB
TypeScript
365 lines
13 KiB
TypeScript
import type { EngineRunDetail, EngineStepAttempt } from "@umt/contracts";
|
||
import { Ban, ChevronDown, ExternalLink, Repeat, ScrollText } from "lucide-react";
|
||
import { type ReactNode, useState } from "react";
|
||
import { Link } from "react-router";
|
||
import { toast } from "sonner";
|
||
import { ENGINE_TERMINAL, useCancelEngineRun, useEngineRun, useRetryEngineRun } from "~/api/engine.ts";
|
||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { JsonView } from "~/components/console/json-view.tsx";
|
||
import { KvList } from "~/components/console/kv-list.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
|
||
import { Button } from "~/components/ui/button.tsx";
|
||
import { errorMessage } from "~/lib/api.ts";
|
||
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
|
||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||
import {
|
||
BUSINESS_KIND_LABEL,
|
||
businessLink,
|
||
engineRunStatus,
|
||
runStatus,
|
||
SOURCE_LABEL,
|
||
sourceOf,
|
||
workflowLabel,
|
||
} from "./labels.ts";
|
||
|
||
// 任务详情:业务摘要 → 时间线 → 引擎步骤(折叠)→ 操作。任务页抽屉与 /tasks/:id 共用。
|
||
|
||
const KIND_LABEL: Record<string, string> = {
|
||
function: "函数",
|
||
sleep: "休眠",
|
||
workflow: "子工作流",
|
||
"signal-send": "发送信号",
|
||
"signal-wait": "等待信号",
|
||
};
|
||
|
||
function stepTone(s: string) {
|
||
if (s === "completed" || s === "succeeded") return "success" as const;
|
||
if (s === "failed") return "destructive" as const;
|
||
return "neutral" as const;
|
||
}
|
||
|
||
/** 错误对象里的一句话 */
|
||
export function errorText(e: unknown): string | null {
|
||
if (e === null || e === undefined) return null;
|
||
if (typeof e === "string") return e;
|
||
const m = (e as { message?: unknown }).message;
|
||
return typeof m === "string" ? m : JSON.stringify(e).slice(0, 300);
|
||
}
|
||
|
||
/** 引擎步骤尝试(原引擎面板详情) */
|
||
function stepItems(steps: EngineStepAttempt[], onOpenRun?: (id: string) => void): TimelineItem[] {
|
||
const seen = new Map<string, number>();
|
||
return steps.map((st) => {
|
||
const n = (seen.get(st.stepName) ?? 0) + 1;
|
||
seen.set(st.stepName, n);
|
||
const hasError = st.error !== null && st.error !== undefined;
|
||
return {
|
||
id: st.id,
|
||
at: st.startedAt ?? st.createdAt,
|
||
tone: stepTone(st.status),
|
||
title: (
|
||
<span>
|
||
{st.stepName}
|
||
<span className="ml-2 text-meta font-normal text-muted-foreground">
|
||
{KIND_LABEL[st.kind] ?? st.kind} · {runStatus(st.status).label}
|
||
{n > 1 ? ` · 第 ${n} 次` : ""}
|
||
</span>
|
||
</span>
|
||
),
|
||
meta: formatDuration(spanSec(st)),
|
||
description: (
|
||
<>
|
||
{st.waitSignal ? (
|
||
<p>
|
||
等待信号 <code className="font-mono">{st.waitSignal}</code>
|
||
{st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}
|
||
</p>
|
||
) : null}
|
||
{st.childWorkflowRunId ? (
|
||
<p>
|
||
子任务{" "}
|
||
<button
|
||
type="button"
|
||
className="cursor-pointer font-mono underline"
|
||
onClick={() => onOpenRun?.(st.childWorkflowRunId ?? "")}
|
||
>
|
||
{st.childWorkflowRunId}
|
||
</button>
|
||
</p>
|
||
) : null}
|
||
<p className="font-mono text-meta">{st.id}</p>
|
||
</>
|
||
),
|
||
children: hasError ? (
|
||
<JsonView label="错误" value={st.error} tone="destructive" defaultOpen />
|
||
) : st.output !== null && st.output !== undefined ? (
|
||
<JsonView label="输出" value={st.output} defaultOpen={false} maxHeight="14rem" />
|
||
) : undefined,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** 业务时间线:创建 → 开始 → 等待 / 失败的步骤 → 结束 */
|
||
function businessTimeline(r: EngineRunDetail): TimelineItem[] {
|
||
const src = sourceOf(r.business?.triggeredBy);
|
||
const items: TimelineItem[] = [
|
||
{ id: "created", at: r.createdAt, title: `创建${src ? `(${SOURCE_LABEL[src]}触发)` : ""}`, tone: "neutral" },
|
||
];
|
||
if (r.startedAt)
|
||
items.push({
|
||
id: "started",
|
||
at: r.startedAt,
|
||
title: r.attempts > 1 ? `开始执行 · 第 ${r.attempts} 次尝试` : "开始执行",
|
||
tone: "neutral",
|
||
});
|
||
for (const st of r.stepAttempts) {
|
||
if (st.waitSignal && !st.finishedAt)
|
||
items.push({
|
||
id: `w-${st.id}`,
|
||
at: st.startedAt ?? st.createdAt,
|
||
title: `等待:${st.stepName}`,
|
||
tone: "warning",
|
||
});
|
||
else if (st.status === "failed")
|
||
items.push({
|
||
id: `f-${st.id}`,
|
||
at: st.finishedAt ?? st.startedAt ?? st.createdAt,
|
||
title: `步骤失败:${st.stepName}`,
|
||
description: errorText(st.error),
|
||
tone: "destructive",
|
||
});
|
||
}
|
||
if (r.finishedAt) {
|
||
const s = engineRunStatus(r);
|
||
items.push({
|
||
id: "finished",
|
||
at: r.finishedAt,
|
||
title: s.label,
|
||
// 与最后一个失败步骤相同的错误不再重复
|
||
description:
|
||
r.status === "failed" && !r.stepAttempts.some((st) => errorText(st.error) === errorText(r.error))
|
||
? errorText(r.error)
|
||
: undefined,
|
||
tone: s.tone,
|
||
meta: formatDuration(spanSec(r)),
|
||
});
|
||
} else if (r.startedAt) {
|
||
items.push({ id: "now", title: "进行中", tone: "neutral", meta: `已 ${formatDuration(spanSec(r))}` });
|
||
}
|
||
return items;
|
||
}
|
||
|
||
/** 取消 / 重跑 */
|
||
export function RunActions({
|
||
r,
|
||
onOpenRun,
|
||
size = "sm",
|
||
}: {
|
||
r: EngineRunDetail;
|
||
onOpenRun?: (id: string) => void;
|
||
size?: "sm" | "xs";
|
||
}) {
|
||
const cancel = useCancelEngineRun();
|
||
const retry = useRetryEngineRun();
|
||
if (!ENGINE_TERMINAL.has(r.status))
|
||
return (
|
||
<ConfirmDialog
|
||
trigger={
|
||
<Button variant="destructive" size={size}>
|
||
<Ban />
|
||
取消任务
|
||
</Button>
|
||
}
|
||
title="取消这个任务?"
|
||
description="正在执行的步骤会在下一个检查点退出,已完成的步骤不回滚。"
|
||
confirmLabel="取消任务"
|
||
cancelLabel="不取消"
|
||
destructive
|
||
onConfirm={async () => {
|
||
try {
|
||
const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" });
|
||
if (res.canceled) toast.success("已取消");
|
||
else toast.warning("任务已结束");
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "取消失败"));
|
||
throw err;
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
return (
|
||
<ConfirmDialog
|
||
trigger={
|
||
<Button variant="outline" size={size} disabled={retry.isPending}>
|
||
<Repeat />
|
||
重跑
|
||
</Button>
|
||
}
|
||
title="重跑这个任务?"
|
||
description="用原始输入新建一条任务,原记录不变。已完成的文件操作不会重复执行。"
|
||
confirmLabel="重跑"
|
||
cancelLabel="不重跑"
|
||
onConfirm={async () => {
|
||
try {
|
||
const res = await retry.mutateAsync({ id: r.id });
|
||
toast.success(`已重新排队(第 ${res.attempt} 次重跑)`);
|
||
onOpenRun?.(res.runId);
|
||
} catch (err) {
|
||
toast.error(errorMessage(err, "重跑失败"));
|
||
throw err;
|
||
}
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Collapsible({ title, children }: { title: string; children: ReactNode }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<section>
|
||
<button
|
||
type="button"
|
||
aria-expanded={open}
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex w-full cursor-pointer items-center gap-2 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase hover:text-foreground"
|
||
>
|
||
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", !open && "-rotate-90")} />
|
||
{title}
|
||
</button>
|
||
{open ? <div className="mt-3 space-y-4">{children}</div> : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 详情主体(不含外框);抽屉和独立页都用 */
|
||
export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun?: (id: string) => void }) {
|
||
const b = r.business;
|
||
const link = businessLink(b, r.input);
|
||
const src = sourceOf(b?.triggeredBy);
|
||
const hasError = r.error !== null && r.error !== undefined;
|
||
const audits = [
|
||
{ to: `/console/audit?targetType=engine_run&targetId=${encodeURIComponent(r.id)}`, label: "本任务的操作记录" },
|
||
...(b?.kind === "schedule"
|
||
? [
|
||
{
|
||
to: `/console/audit?targetType=scheduled_job&targetId=${encodeURIComponent(b.refId)}`,
|
||
label: "定时任务的修改记录",
|
||
},
|
||
]
|
||
: []),
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<DetailSection title="业务摘要">
|
||
<KvList
|
||
items={[
|
||
{
|
||
label: "关联对象",
|
||
value: b ? (
|
||
link ? (
|
||
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
|
||
{BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId}
|
||
<ExternalLink className="h-3 w-3" />
|
||
</Link>
|
||
) : (
|
||
`${BUSINESS_KIND_LABEL[b.kind]} · ${b.title ?? b.refId}`
|
||
)
|
||
) : (
|
||
"无"
|
||
),
|
||
wide: true,
|
||
},
|
||
{ label: "做什么", value: workflowLabel(r.workflowName) },
|
||
{ label: "来源", value: src ? SOURCE_LABEL[src] : "未知" },
|
||
{
|
||
label: "开始",
|
||
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
|
||
},
|
||
{ label: "耗时", value: formatDuration(spanSec(r)) },
|
||
]}
|
||
/>
|
||
</DetailSection>
|
||
|
||
<DetailSection title="时间线">
|
||
<Timeline items={businessTimeline(r)} />
|
||
</DetailSection>
|
||
|
||
<DetailSection title="相关审计">
|
||
<ul className="flex flex-wrap gap-x-4 gap-y-1 text-caption">
|
||
{audits.map((a) => (
|
||
<li key={a.to}>
|
||
<Link
|
||
to={a.to}
|
||
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||
>
|
||
<ScrollText className="h-3.5 w-3.5" />
|
||
{a.label}
|
||
</Link>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</DetailSection>
|
||
|
||
<Collapsible title={`引擎步骤(${r.stepAttempts.length})`}>
|
||
<KvList
|
||
items={[
|
||
{ label: "工作流", value: `${r.workflowName}${r.version ? ` ${r.version}` : ""}`, mono: true },
|
||
{ label: "运行 ID", value: r.id, mono: true },
|
||
{ label: "尝试次数", value: r.attempts },
|
||
{ label: "Worker", value: r.workerId, mono: true },
|
||
{ label: "可执行于", value: formatDateTime(r.availableAt) },
|
||
{ label: "截止", value: formatDateTime(r.deadlineAt) },
|
||
{ label: "父步骤", value: r.parentStepAttemptId, mono: true },
|
||
]}
|
||
/>
|
||
{r.stepAttempts.length === 0 ? (
|
||
<EmptyState plain title="还没有步骤" className="py-6" />
|
||
) : (
|
||
<Timeline items={stepItems(r.stepAttempts, onOpenRun)} />
|
||
)}
|
||
{hasError ? <JsonView label="错误" value={r.error} tone="destructive" /> : null}
|
||
<JsonView label="输入" value={r.input} defaultOpen={false} />
|
||
{!hasError ? <JsonView label="输出" value={r.output} defaultOpen={false} /> : null}
|
||
{r.signals.length ? (
|
||
<div className="space-y-2">
|
||
<p className="text-meta text-muted-foreground">信号({r.signals.length})</p>
|
||
{r.signals.map((sg) => (
|
||
<div key={sg.id} className="min-w-0">
|
||
<p className="text-sm">
|
||
<code className="font-mono">{sg.signal}</code>
|
||
<span className="ml-2 text-meta text-muted-foreground">{formatDateTime(sg.createdAt)}</span>
|
||
</p>
|
||
<JsonView value={sg.data} maxHeight="10rem" className="mt-1" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</Collapsible>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** 头部一行:状态 + 业务类型 */
|
||
export function TaskStatusLine({ r }: { r: EngineRunDetail }) {
|
||
const s = engineRunStatus(r);
|
||
return (
|
||
<span className="flex flex-wrap items-center gap-2">
|
||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||
<span>{workflowLabel(r.workflowName)}</span>
|
||
{r.business ? <span>· {BUSINESS_KIND_LABEL[r.business.kind]}</span> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/** 按 id 取数并渲染(抽屉用) */
|
||
export function TaskDetail({ runId, onOpenRun }: { runId: string; onOpenRun?: (id: string) => void }) {
|
||
const q = useEngineRun(runId);
|
||
if (q.isPending) return <LoadingState rows={6} />;
|
||
if (q.isError) return <ErrorState plain error={q.error} onRetry={() => q.refetch()} />;
|
||
return <TaskDetailBody r={q.data} onOpenRun={onOpenRun} />;
|
||
}
|