feat(web): W4-A wire console to real endpoints

- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local
  mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status;
  stream auto-follow only while the user stays near the bottom (abort stays reachable)
- settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript)
- download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of
  time-window engine run matching and path matching; new business-task tab with cancel/retry
- workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons
- preference tag revision history; subscription search by title/kind; identity/alias/subscription
  lists show entity titles; slot cards show current asset summary; policy preview sample titles
- DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary /
  OrganizePlanArtifactContent taken from contracts
- docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 21:32:11 +08:00
1 parent 34e8751b45
commit 1023642ef9
30 files changed
+703 -271

No files matched your search

+73 -79
View File
@@ -4,19 +4,21 @@ import {
cursorPage,
Decision,
type DownloadTask,
EngineRun,
EngineRunDetail,
type PublicOrganizationRecord,
PublicOrganizationRecord,
SourceRelease,
Task,
type TaskKind,
type TaskStatus,
} from "@umt/contracts";
import { PLAN_STATUS_LABEL } from "./downloads.ts";
import { apiParsed, cleanParams } from "./parse.ts";
import { useRecordsForEntity } from "./records.ts";
import { apiParsed } from "./parse.ts";
// 业务任务视图:引擎 run 关联 + 按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
// 关联键:plan → download_task(instanceId + hash)→ organization_record → decision/agent_run。
// 业务任务视图:按 trace 汇总的业务时间线(参考 MovieClaw task-activity)
// 关联键全部由服务端给出:plan →(/tasks?subjectId=planId)submitDownload → download_task
// →(/tasks?subjectId=downloadTaskId)verifyAndImport →(/organization-records?downloadTaskId=)整理记录 → decision/agent_run。
const EnginePage = cursorPage(EngineRun);
const TaskPage = cursorPage(Task);
const RecordPage = cursorPage(PublicOrganizationRecord);
const DecisionPage = cursorPage(Decision);
const RESOLUTION_LABEL = {
@@ -63,44 +65,55 @@ export function useSubjectDecisions(subjectIds: string[]) {
});
}
/**
* 引擎 run 关联:submitDownload 的 input 带 acquisitionPlanId,verifyAndImport 的 input 带 downloadTaskId。
* 服务端没有"按业务对象查 run"的端点,这里在时间窗内取少量候选 run 读详情后按 input 精确匹配。
*/
export function useTraceEngineRuns(opts: { planId?: string | null; downloadTaskId?: string; since?: string }) {
const { planId, downloadTaskId, since } = opts;
/** 业务任务:submitDownload 的 subject 是计划,verifyAndImport 的 subject 是下载任务(服务端写入 task 表) */
export function useTraceTasks(opts: { planId?: string | null; downloadTaskId?: string }) {
const ids = [opts.planId, opts.downloadTaskId].filter((x): x is string => !!x);
return useQuery({
queryKey: ["engine", "trace", planId ?? "", downloadTaskId ?? "", since ?? ""],
enabled: !!since && (!!planId || !!downloadTaskId),
staleTime: 60_000,
queryFn: async ({ signal }) => {
const from = since ? new Date(new Date(since).getTime() - 10 * 60_000).toISOString() : undefined;
const lists = await Promise.all(
["submitDownload", "verifyAndImport"].map((workflowName) =>
apiParsed(EnginePage, "/api/v1/engine/runs", {
params: cleanParams({ workflowName, from, limit: 15 }),
signal,
}).catch(() => ({ items: [], nextCursor: null })),
),
queryKey: ["tasks", "trace", ids],
enabled: ids.length > 0,
queryFn: async ({ signal }): Promise<Task[]> => {
const pages = await Promise.all(
ids.map((subjectId) => apiParsed(TaskPage, "/api/v1/tasks", { params: { subjectId, limit: 50 }, signal })),
);
const candidates = lists.flatMap((l) => l.items);
const details = await Promise.all(
candidates.map((r) =>
apiParsed(EngineRunDetail, "/api/v1/engine/runs/:id", { pathParams: { id: r.id }, signal }).catch(() => null),
),
);
return details.filter((d): d is EngineRunDetail => {
if (!d) return false;
const input = (d.input ?? {}) as Record<string, unknown>;
return (
(!!planId && input.acquisitionPlanId === planId) ||
(!!downloadTaskId && input.downloadTaskId === downloadTaskId)
);
});
return pages.flatMap((p) => p.items);
},
refetchInterval: (q) => (q.state.data?.some((t) => ACTIVE_TASK.includes(t.status)) ? 5_000 : false),
});
}
const ACTIVE_TASK: TaskStatus[] = ["pending", "running", "waiting"];
/** 这个下载产生的整理记录(服务端按 download_task_id 关联) */
export function useRecordsForDownload(downloadTaskId: string | undefined) {
return useQuery({
queryKey: ["records", "download", downloadTaskId ?? ""],
enabled: !!downloadTaskId,
queryFn: ({ signal }) =>
apiParsed(RecordPage, "/api/v1/organization-records", {
params: { downloadTaskId: downloadTaskId ?? "", limit: 50 },
signal,
}),
});
}
const TASK_KIND_LABEL: Partial<Record<TaskKind, string>> = {
submitDownload: "提交下载",
verifyAndImport: "核验入库",
evaluateAcquisition: "评估候选",
cleanupDownloadCache: "清理缓存",
resolveDecision: "处理待办",
};
const TASK_STATUS_TONE: Record<TaskStatus, TimelineTone> = {
pending: "neutral",
running: "neutral",
waiting: "warning",
succeeded: "success",
failed: "destructive",
cancelled: "neutral",
dead: "destructive",
};
const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
queued: "neutral",
running: "neutral",
@@ -110,18 +123,6 @@ const RECORD_TONE: Record<PublicOrganizationRecord["status"], TimelineTone> = {
cancelled: "neutral",
};
/** 整理记录与下载的关联:同实例 + 源路径落在下载内容路径下(合同里没有 downloadTaskId,只能按路径匹配) */
export function recordsForDownload(records: PublicOrganizationRecord[], task: DownloadTask) {
const base = (task.contentPath ?? task.savePath ?? "").replace(/\/+$/, "");
return records.filter(
(r) =>
r.source.downloaderInstanceId === task.instanceId &&
!!base &&
!!r.source.path &&
(r.source.path === base || r.source.path.startsWith(`${base}/`)),
);
}
/** 汇总一个下载 trace 的业务事件,按时间升序 */
export function buildTimeline(input: {
task: DownloadTask;
@@ -129,9 +130,9 @@ export function buildTimeline(input: {
plan?: AcquisitionPlan | null;
records?: PublicOrganizationRecord[];
decisions?: Decision[];
engineRuns?: EngineRunDetail[];
tasks?: Task[];
}): TimelineEvent[] {
const { task, release, plan, records = [], decisions = [], engineRuns = [] } = input;
const { task, release, plan, records = [], decisions = [], tasks = [] } = input;
const ev: TimelineEvent[] = [];
if (release) {
ev.push({
@@ -253,21 +254,20 @@ export function buildTimeline(input: {
href: `/console/agent/${d.agentRunId}`,
});
}
for (const r of engineRuns) {
for (const t of tasks) {
const err = t.lastError
? String((t.lastError as { message?: unknown }).message ?? JSON.stringify(t.lastError))
: null;
ev.push({
key: `eng-${r.id}`,
key: `task-${t.id}`,
stage: "engine",
at: r.startedAt ?? r.createdAt,
title: `引擎 ${r.workflowName}:${r.status}`,
detail: `${r.stepAttempts.length} 个步骤尝试 · 第 ${r.attempts} 次`,
tone:
r.status === "failed"
? "destructive"
: r.status === "completed" || r.status === "succeeded"
? "success"
: "neutral",
href: `/console/engine/${encodeURIComponent(r.id)}`,
ongoing: r.status === "running" || r.status === "pending" || r.status === "sleeping",
at: t.startedAt ?? t.createdAt,
title: `任务 ${TASK_KIND_LABEL[t.kind] ?? t.kind}:${t.status}`,
detail:
[t.attempts > 1 ? `第 ${t.attempts} 次尝试` : null, t.waitReason, err].filter(Boolean).join(" · ") || null,
tone: TASK_STATUS_TONE[t.status],
...(t.engineRunId ? { href: `/console/engine/${encodeURIComponent(t.engineRunId)}` } : {}),
ongoing: ACTIVE_TASK.includes(t.status),
});
}
return ev.sort((a, b) => a.at.localeCompare(b.at));
@@ -276,30 +276,24 @@ export function buildTimeline(input: {
/** 一站式取 trace 数据 */
export function useDownloadTrace(task: DownloadTask | undefined, plan: AcquisitionPlan | null | undefined) {
const release = useSourceRelease(task?.sourceReleaseId ?? plan?.sourceReleaseId);
const entityId = release.data?.mediaEntityId ?? null;
const records = useRecordsForEntity(entityId);
const records = useRecordsForDownload(task?.id);
const decisions = useSubjectDecisions([task?.id ?? "", task?.acquisitionPlanId ?? ""]);
const engine = useTraceEngineRuns({
planId: task?.acquisitionPlanId,
downloadTaskId: task?.id,
since: plan?.createdAt ?? task?.createdAt,
});
const matched = task ? recordsForDownload(records.data?.items ?? [], task) : [];
const tasks = useTraceTasks({ planId: task?.acquisitionPlanId, downloadTaskId: task?.id });
return {
release,
records,
decisions,
engine,
tasks,
events: task
? buildTimeline({
task,
release: release.data,
plan,
records: matched,
records: records.data?.items ?? [],
decisions: decisions.data,
engineRuns: engine.data,
tasks: tasks.data,
})
: [],
loading: release.isLoading || records.isLoading || decisions.isLoading || engine.isLoading,
loading: release.isLoading || records.isLoading || decisions.isLoading || tasks.isLoading,
};
}