- 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]>
259 lines
9.8 KiB
TypeScript
259 lines
9.8 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|