Files
UHD_Media_Tool/apps/web/app/routes/console/engine.$runId.tsx
T
rootandClaude Opus 5.5 ceff9cf51f feat(ao): W4-E3 manual organize guard, series completion, engine retry, v0.1.0
- POST /organization-records: sourcePaths must resolve (realpath) under a
  registered library_root or downloader path-mapping host path; `..` and
  symlink escapes rejected (400), dryRun included. Move requires an admin
  session, or an API key with an automation_grant covering the entity or a
  matching one-time approval (consumed on use), else 403. Manual Move from a
  managed download_task with unsatisfied H&R returns 409 and opens a
  decision; never silently downgraded to Copy.
- Series slot completion: total episode count is unknown while units are
  provisional, so the slot is not satisfied and the subscription keeps
  tracking; coverage is registered per actual file (creating provisional
  episode units), and already_in_library files count as verified presence.
- POST /engine/runs/{id}/retry (admin): re-enqueue by original task name and
  raw input with idempotency key retry:<id>:<n>, audited; retry button on
  the run detail page.
- E7 regular series e2e, E8 AT24 tag removal / task disappearance e2e.
- All workspace members and root set to version 0.1.0; CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 22:47:11 +08:00

259 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { EngineStepAttempt } from "@umt/contracts";
import { ArrowLeft, Ban, Repeat, RotateCw } from "lucide-react";
import { Link, useNavigate, useParams } 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 { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Timeline, type TimelineItem } from "~/components/console/timeline.tsx";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { engineRunLabel, formatDuration, formatRelative, spanSec as runDurationSec } from "~/lib/labels.ts";
import { formatDateTime } from "~/lib/utils.ts";
export const meta = () => [{ title: "Run 详情 · 工作流引擎" }];
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;
}
function stepItems(steps: EngineStepAttempt[]): 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 status =
st.status === "running"
? "运行中"
: st.status === "failed"
? "失败"
: st.status === "completed" || st.status === "succeeded"
? "完成"
: st.status;
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} · {status}
{n > 1 ? ` · 第 ${n} 次` : ""}
</span>
</span>
),
meta: formatDuration(runDurationSec(st)),
description: (
<>
{st.waitSignal ? (
<p>
等待信号 <code className="font-mono">{st.waitSignal}</code>
{st.timeoutAt ? ` · 超时于 ${formatDateTime(st.timeoutAt)}` : ""}
</p>
) : null}
{st.childWorkflowRunId ? (
<p>
子工作流{" "}
<Link to={consolePath("engine", st.childWorkflowRunId)} className="font-mono underline">
{st.childWorkflowRunId}
</Link>
</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,
};
});
}
export default function EngineRunPage() {
const { runId } = useParams();
const q = useEngineRun(runId);
const cancel = useCancelEngineRun();
const retry = useRetryEngineRun();
const navigate = useNavigate();
const back = (
<Link
to={consolePath("engine")}
className={buttonVariants({ variant: "ghost", size: "sm", className: "-ml-2 mb-2" })}
>
<ArrowLeft />
返回 runs
</Link>
);
if (q.isPending) {
return (
<>
{back}
<LoadingState rows={8} />
</>
);
}
if (q.isError) {
return (
<>
{back}
<ErrorState title="加载 run 失败" error={q.error} onRetry={() => q.refetch()} />
</>
);
}
const r = q.data;
const st = engineRunLabel(r.status);
const terminal = ENGINE_TERMINAL.has(r.status);
const hasError = r.error !== null && r.error !== undefined;
return (
<>
{back}
<PageHeader
title={r.workflowName}
description={
<span className="flex flex-wrap items-center gap-2">
<StatusBadge tone={st.tone}>{st.label}</StatusBadge>
{r.version ? <span>{r.version}</span> : null}
<span className="font-mono text-meta break-all">{r.id}</span>
</span>
}
actions={
<>
<Button variant="ghost" size="sm" onClick={() => q.refetch()} disabled={q.isFetching}>
<RotateCw className={q.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
{!terminal ? (
<ConfirmDialog
trigger={
<Button variant="destructive" size="sm">
<Ban />
取消 run
</Button>
}
title="取消这个 run?"
description="先标记业务侧取消,再通知引擎停止;正在执行的步骤会在下一个检查点退出。"
confirmLabel="取消 run"
cancelLabel="不取消"
destructive
onConfirm={async () => {
try {
const res = await cancel.mutateAsync({ id: r.id, reason: "控制台手动取消" });
if (res.canceled) toast.success("已取消");
else toast.warning("run 已结束,无需取消");
} catch (err) {
toast.error(errorMessage(err, "取消失败"));
throw err;
}
}}
/>
) : (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={retry.isPending}>
<Repeat />
重跑
</Button>
}
title="重跑这个 run?"
description="按原任务名和原始输入新建一个 run(新的幂等键),原 run 保持不变。已完成的文件操作由任务自己的操作账本去重。"
confirmLabel="重跑"
cancelLabel="不重跑"
onConfirm={async () => {
try {
const res = await retry.mutateAsync({ id: r.id });
toast.success(`已重新入队(第 ${res.attempt} 次重跑)`);
navigate(consolePath("engine", res.runId));
} catch (err) {
toast.error(errorMessage(err, "重跑失败"));
throw err;
}
}}
/>
)}
</>
}
/>
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<div className="min-w-0 space-y-4">
<section className="pg-slab rounded-xl p-5">
<DetailSection title="概览">
<KvList
items={[
{
label: "开始",
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
},
{ label: "耗时", value: formatDuration(runDurationSec(r)) },
{ label: "结束", value: formatDateTime(r.finishedAt) },
{ label: "尝试次数", value: r.attempts },
{ label: "可执行于", value: formatDateTime(r.availableAt) },
{ label: "截止", value: formatDateTime(r.deadlineAt) },
{ label: "Worker", value: r.workerId, mono: true },
{ label: "父步骤", value: r.parentStepAttemptId, mono: true },
]}
/>
</DetailSection>
</section>
<section className="pg-slab space-y-4 rounded-xl p-5">
{hasError ? <JsonView label="错误" value={r.error} tone="destructive" /> : null}
<JsonView label="输入" value={r.input} defaultOpen={!hasError} />
{!hasError ? <JsonView label="输出" value={r.output} /> : null}
</section>
<section className="pg-slab rounded-xl p-5">
<DetailSection title={`信号(${r.signals.length})`}>
{r.signals.length === 0 ? (
<p className="text-caption text-muted-foreground">没有投递到这个 run 的信号。</p>
) : (
<ul className="space-y-3">
{r.signals.map((sg) => (
<li 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" />
</li>
))}
</ul>
)}
</DetailSection>
</section>
</div>
<section className="pg-slab min-w-0 rounded-xl p-5">
<DetailSection title={`步骤尝试(${r.stepAttempts.length})`}>
{r.stepAttempts.length === 0 ? (
<EmptyState plain title="还没有执行任何步骤" className="py-8" />
) : (
<Timeline items={stepItems(r.stepAttempts)} />
)}
</DetailSection>
</section>
</div>
</>
);
}