ソースを参照

可视化:清理旧 MissionControl 并收紧前端工程检查

删除旧 MissionControl、task-tree 和对应旧 E2E;更新包名、依赖锁、严格 TypeScript、Vitest JSX 与 Playwright 配置,并新增 Journey 浏览器流程测试。
SamLee 12 時間 前
コミット
15464a091d

+ 0 - 407
visualization/frontend/components/MissionControl.tsx

@@ -1,407 +0,0 @@
-"use client";
-
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import {
-  ArrowDownToLine,
-  ArrowUpFromLine,
-  Braces,
-  Check,
-  ChevronRight,
-  CircleDot,
-  Database,
-  FileCode2,
-  LoaderCircle,
-  RefreshCw,
-  Search,
-  X,
-} from "lucide-react";
-import { fetchExecution, fetchRuns, fetchTaskDetail } from "@/lib/api";
-import { buildTaskForest, statusLabel, taskKindLabel, type TaskBranch } from "@/lib/task-tree";
-import type { AttemptDetail, ExecutionView, PlannerTurn, RunSummary, TaskDetail, TaskNode } from "@/lib/types";
-
-type DetailTab = "lifecycle" | "attempt" | "data" | "code";
-
-function shortId(value: string | null) {
-  return value ? `${value.slice(0, 8)}…` : "—";
-}
-
-function formatTime(value: string) {
-  if (!value) return "—";
-  const parsed = new Date(value);
-  return Number.isNaN(parsed.valueOf()) ? value : parsed.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
-}
-
-function JsonBlock({ value }: { value: unknown }) {
-  return <pre className="json-block">{JSON.stringify(value, null, 2)}</pre>;
-}
-
-function Empty({ children }: { children: React.ReactNode }) {
-  return <p className="empty-copy">{children}</p>;
-}
-
-function StatusMark({ status }: { status: string }) {
-  const complete = ["completed", "passed", "accept", "submitted", "succeeded"].includes(status);
-  return <span className={`status-mark status-${status}`}>{complete ? <Check size={11} /> : <CircleDot size={10} />}{statusLabel(status)}</span>;
-}
-
-function Header({ runs, view, selectedId, onSelect, onRefresh, refreshing }: {
-  runs: RunSummary[];
-  view: ExecutionView;
-  selectedId: number;
-  onSelect: (id: number) => void;
-  onRefresh: () => void;
-  refreshing: boolean;
-}) {
-  return <header className="mission-header">
-    <div className="mission-kicker">
-      <span>REAL EXECUTION OBSERVER</span>
-      <span>TaskLedger revision {view.run.revision}</span>
-      <span>每 15 秒只读刷新</span>
-    </div>
-    <div className="mission-title-row">
-      <div>
-        <p>创作总目标</p>
-        <h1>{view.run.title}</h1>
-      </div>
-      <div className="run-actions">
-        <label>
-          <span>真实 Script Build</span>
-          <select value={selectedId} onChange={(event) => onSelect(Number(event.target.value))}>
-            {runs.map((run) => <option key={run.script_build_id} value={run.script_build_id}>#{run.script_build_id} · {run.task_count} Tasks</option>)}
-          </select>
-        </label>
-        <button onClick={onRefresh} aria-label="刷新真实运行" title="刷新真实运行"><RefreshCw className={refreshing ? "spin" : ""} size={17} /></button>
-      </div>
-    </div>
-    <div className="mission-state">
-      <span><b>{view.run.checkpoint}</b></span>
-      <span>当前缺口:{view.run.current_gap}</span>
-      <span>{view.run.task_count} Tasks / {view.run.attempt_count} Attempts / {view.run.planner_turn_count} Planner moves</span>
-    </div>
-  </header>;
-}
-
-function PlannerAxis({ turns, selectedId, tasks, onSelect }: {
-  turns: PlannerTurn[];
-  selectedId: string | null;
-  tasks: TaskNode[];
-  onSelect: (id: string) => void;
-}) {
-  const selectedRef = useRef<HTMLButtonElement | null>(null);
-  const taskById = useMemo(() => new Map(tasks.map((task) => [task.task_id, task])), [tasks]);
-  useEffect(() => {
-    selectedRef.current?.scrollIntoView({ block: "nearest" });
-  }, [selectedId]);
-  return <section className="planner-panel" aria-label="全局 Planner 主轴">
-    <div className="panel-heading">
-      <div><span>01 / GLOBAL PLANNER</span><h2>全局 Planner 在树上移动焦点</h2></div>
-      <small>选择一步,右侧回看当时已经长出的 Task</small>
-    </div>
-    <div className="planner-equation">
-      <b>PLAN</b><i>→</i><b>EXECUTE</b><i>→</i><b>VALIDATE</b><i>→</i><b>DECIDE</b><i>↻</i>
-    </div>
-    <ol className="planner-turns">
-      {turns.map((turn) => {
-        const focus = turn.focus_task_id ? taskById.get(turn.focus_task_id) : null;
-        const active = turn.turn_id === selectedId;
-        return <li key={turn.turn_id}>
-          <button ref={active ? selectedRef : undefined} className={active ? "active" : ""} onClick={() => onSelect(turn.turn_id)}>
-            <span className="turn-index">{String(turn.order).padStart(2, "0")}</span>
-            <span className="turn-copy">
-              <small>{turn.phase.toUpperCase()} · {formatTime(turn.created_at)}</small>
-              <strong>{turn.action_label}</strong>
-              <em>{focus ? `${focus.display_path} ${taskKindLabel(focus.task_kind)}` : "创作总目标"}</em>
-            </span>
-            <ChevronRight size={14} />
-          </button>
-        </li>;
-      })}
-    </ol>
-  </section>;
-}
-
-function TaskRecord({ task, selected, focused, changed, onSelect }: {
-  task: TaskNode;
-  selected: boolean;
-  focused: boolean;
-  changed: boolean;
-  onSelect: (task: TaskNode) => void;
-}) {
-  return <button
-    className={`task-record ${task.task_kind === "root" ? "root-task" : ""} ${selected ? "selected" : ""} ${focused ? "focused" : ""} ${changed ? "changed" : ""}`}
-    onClick={() => onSelect(task)}
-  >
-    <span className="task-path">{task.display_path}</span>
-    <span className="task-body">
-      <small>{taskKindLabel(task.task_kind)}</small>
-      <strong>{task.objective}</strong>
-      <em>{task.attempt_count ? `${task.attempt_count} Attempt` : "尚未执行"}{task.validation_count ? ` · ${task.validation_count} Validation` : ""}</em>
-    </span>
-    <StatusMark status={task.status} />
-  </button>;
-}
-
-function TaskBranchView({ branch, selectedTaskId, focusedId, changedIds, onSelect }: {
-  branch: TaskBranch;
-  selectedTaskId: string | null;
-  focusedId: string | null;
-  changedIds: Set<string>;
-  onSelect: (task: TaskNode) => void;
-}) {
-  return <li className="task-branch">
-    <TaskRecord
-      task={branch.task}
-      selected={selectedTaskId === branch.task.task_id}
-      focused={focusedId === branch.task.task_id}
-      changed={changedIds.has(branch.task.task_id)}
-      onSelect={onSelect}
-    />
-    {branch.children.length ? <ol className="task-children">{branch.children.map((child) => <TaskBranchView
-      key={child.task.task_id}
-      branch={child}
-      selectedTaskId={selectedTaskId}
-      focusedId={focusedId}
-      changedIds={changedIds}
-      onSelect={onSelect}
-    />)}</ol> : null}
-  </li>;
-}
-
-function PlannerTurnNote({ turn, taskById }: { turn: PlannerTurn; taskById: Map<string, TaskNode> }) {
-  const focus = turn.focus_task_id ? taskById.get(turn.focus_task_id) : null;
-  return <article className="turn-note">
-    <div><span>Planner move {turn.order}</span><StatusMark status={turn.status} /></div>
-    <h3>{turn.action_label}{focus ? `:${focus.display_path} ${taskKindLabel(focus.task_kind)}` : ""}</h3>
-    <p>{turn.summary}</p>
-    <details>
-      <summary>查看这一步的可观察说明与工具输入</summary>
-      <h4>模型主动输出的说明(不是隐藏思维链)</h4>
-      <pre>{turn.observable_reasoning || "本步没有输出文字说明。"}</pre>
-      <h4>{turn.tool_name} arguments</h4>
-      <JsonBlock value={turn.tool_arguments} />
-      <h4>工具结果摘要</h4>
-      <pre>{turn.result_summary}</pre>
-    </details>
-  </article>;
-}
-
-function TaskTreePanel({ view, turn, query, onQuery, selectedTaskId, onSelect }: {
-  view: ExecutionView;
-  turn: PlannerTurn | null;
-  query: string;
-  onQuery: (value: string) => void;
-  selectedTaskId: string | null;
-  onSelect: (task: TaskNode) => void;
-}) {
-  const allVisible = useMemo(() => new Set(turn?.visible_task_ids ?? view.tasks.map((task) => task.task_id)), [turn, view.tasks]);
-  const matching = useMemo(() => {
-    if (!query.trim()) return allVisible;
-    const needle = query.trim().toLowerCase();
-    const matches = new Set<string>();
-    const byId = new Map(view.tasks.map((task) => [task.task_id, task]));
-    for (const task of view.tasks) {
-      if (!allVisible.has(task.task_id)) continue;
-      if (`${task.display_path} ${task.task_kind} ${task.objective}`.toLowerCase().includes(needle)) {
-        let current: TaskNode | undefined = task;
-        while (current) {
-          matches.add(current.task_id);
-          current = current.parent_task_id ? byId.get(current.parent_task_id) : undefined;
-        }
-      }
-    }
-    return matches;
-  }, [allVisible, query, view.tasks]);
-  const forest = useMemo(() => buildTaskForest(view.tasks, matching), [matching, view.tasks]);
-  const changedIds = useMemo(() => new Set(turn?.affected_task_ids ?? []), [turn]);
-  const taskById = useMemo(() => new Map(view.tasks.map((task) => [task.task_id, task])), [view.tasks]);
-  return <section className="tree-panel" aria-label="动态 Task 树">
-    <div className="panel-heading tree-heading">
-      <div><span>02 / DYNAMIC TASK TREE</span><h2>围绕创作总目标动态生长</h2></div>
-      <label className="tree-search"><Search size={15} /><input value={query} onChange={(event) => onQuery(event.target.value)} placeholder="搜索 Task…" />{query ? <button onClick={() => onQuery("")}><X size={13} /></button> : null}</label>
-    </div>
-    {turn ? <PlannerTurnNote turn={turn} taskById={taskById} /> : null}
-    <div className="tree-key"><span><i className="key-focus" />Planner 当前焦点</span><span><i className="key-change" />本步涉及</span><span><i />当前 ledger 状态</span></div>
-    <div className="tree-scroll">
-      {forest.length ? <ol className="task-tree">{forest.map((branch) => <TaskBranchView
-        key={branch.task.task_id}
-        branch={branch}
-        selectedTaskId={selectedTaskId}
-        focusedId={turn?.focus_task_id ?? null}
-        changedIds={changedIds}
-        onSelect={onSelect}
-      />)}</ol> : <Empty>没有符合条件的 Task。</Empty>}
-    </div>
-  </section>;
-}
-
-function LifecycleView({ detail }: { detail: TaskDetail }) {
-  return <div className="detail-section">
-    <div className="loop-line"><b>Planner</b><i>创建 / 决定</i><b>Worker</b><i>提交</i><b>Validator</b><i>验收</i><b>Planner</b><i>↻</i></div>
-    <ol className="lifecycle-list">{detail.lifecycle.map((event) => <li key={event.event_id}>
-      <span className={`event-glyph event-${event.kind}`} />
-      <div><small>{formatTime(event.created_at)} · {event.kind}</small><strong>{event.title}</strong><p>{event.summary || "无补充说明"}</p></div>
-      <StatusMark status={event.status} />
-    </li>)}</ol>
-  </div>;
-}
-
-function AttemptLoop({ attempt }: { attempt: AttemptDetail | null }) {
-  if (!attempt) return <Empty>这个 Task 还没有 Attempt。</Empty>;
-  return <div className="detail-section">
-    <div className="attempt-meta"><span>{attempt.worker_preset}</span><span>{attempt.total_tokens.toLocaleString()} tokens</span><span>¥/cost {attempt.total_cost.toFixed(4)}</span><StatusMark status={attempt.status} /></div>
-    <ol className="worker-loop">{attempt.steps.map((step, index) => <li key={`${step.sequence}-${step.step_kind}-${index}`} className={`step-${step.step_kind}`}>
-      <span>{String(index + 1).padStart(2, "0")}</span>
-      <div><small>{step.role} · seq {step.sequence}</small><strong>{step.title}</strong>{step.text ? <pre>{step.text}</pre> : null}{step.tool_arguments ? <JsonBlock value={step.tool_arguments} /> : null}</div>
-    </li>)}</ol>
-  </div>;
-}
-
-function DataView({ detail }: { detail: TaskDetail }) {
-  const groups = [
-    { icon: ArrowDownToLine, title: "进入 Task 的数据", note: "在冻结输入闭包中,不代表 Worker 一定逐项读取", values: detail.data_usage.inherited_inputs },
-    { icon: Database, title: "Worker 确认读取", note: "来自真实 worker_trace 的 read/get/search/query 工具调用", values: detail.data_usage.confirmed_reads },
-    { icon: ArrowUpFromLine, title: "Task 产生的数据", note: "来自 Attempt submission 的 Artifact 引用", values: detail.data_usage.produced_outputs },
-    { icon: Check, title: "采用与回退", note: "来自 PlannerDecision 持久化记录", values: detail.data_usage.adoption },
-  ];
-  return <div className="data-groups">{groups.map((group) => <section key={group.title}>
-    <header><group.icon size={16} /><div><h3>{group.title}</h3><p>{group.note}</p></div><b>{group.values.length}</b></header>
-    {group.values.length ? group.values.map((value, index) => <JsonBlock key={index} value={value} />) : <Empty>没有对应记录。</Empty>}
-  </section>)}</div>;
-}
-
-function CodePromptView({ detail, attempt }: { detail: TaskDetail; attempt: AttemptDetail | null }) {
-  const explanations = attempt?.steps.filter((step) => step.step_kind === "explanation") ?? [];
-  return <div className="detail-section code-view">
-    <section><h3><Braces size={16} /> Process / Contract</h3><p>Task 的执行边界与输入输出约束。</p>{detail.contract ? <JsonBlock value={detail.contract} /> : <Empty>Root Task 没有独立 ScriptTaskContract。</Empty>}</section>
-    <section><h3><FileCode2 size={16} /> Prompt</h3><p>当前 Attempt 实际持久化的 Worker system prompt。</p><pre>{attempt?.prompt || "尚无 Worker Prompt。"}</pre></section>
-    <section><h3><CircleDot size={16} /> Observable reasoning</h3><p>只展示模型主动输出文字、工具动作和验收理由;不把它命名为隐藏思维链。</p>{explanations.length ? explanations.map((step, index) => <pre key={index}>{step.text}</pre>) : <Empty>这个 Attempt 没有主动输出说明文字。</Empty>}</section>
-  </div>;
-}
-
-function TaskInspector({ detail, loading, tab, onTab, attemptId, onAttempt, onClose }: {
-  detail: TaskDetail | null;
-  loading: boolean;
-  tab: DetailTab;
-  onTab: (tab: DetailTab) => void;
-  attemptId: string | null;
-  onAttempt: (id: string) => void;
-  onClose: () => void;
-}) {
-  if (loading || !detail) return <aside className="task-inspector"><div className="drawer-loading"><LoaderCircle className="spin" /><span>读取真实 Task Trace…</span><button onClick={onClose}><X /></button></div></aside>;
-  const attempt = detail.attempts.find((item) => item.attempt_id === attemptId) ?? detail.attempts.at(-1) ?? null;
-  return <aside className="task-inspector">
-    <header className="drawer-header">
-      <div><span>{detail.task.display_path} / {taskKindLabel(detail.task.task_kind)}</span><h2>{detail.task.objective}</h2></div>
-      <button onClick={onClose} aria-label="关闭 Task 详情"><X size={18} /></button>
-    </header>
-    <div className="drawer-facts"><StatusMark status={detail.task.status} /><code>{shortId(detail.task.task_id)}</code><span>返回给 {shortId(detail.task.parent_task_id)}</span></div>
-    {detail.attempts.length ? <div className="attempt-switch"><span>Attempt</span>{detail.attempts.map((item, index) => <button key={item.attempt_id} className={item.attempt_id === attempt?.attempt_id ? "active" : ""} onClick={() => onAttempt(item.attempt_id)}>{index + 1} · {statusLabel(item.status)}</button>)}</div> : null}
-    <nav className="drawer-tabs">
-      {([ ["lifecycle", "Task 生命周期"], ["attempt", "Worker Loop"], ["data", "Data 使用"], ["code", "Code / Prompt"] ] as Array<[DetailTab, string]>).map(([id, label]) => <button key={id} className={tab === id ? "active" : ""} onClick={() => onTab(id)}>{label}</button>)}
-    </nav>
-    <div className="drawer-scroll">
-      {tab === "lifecycle" ? <LifecycleView detail={detail} /> : null}
-      {tab === "attempt" ? <AttemptLoop attempt={attempt} /> : null}
-      {tab === "data" ? <DataView detail={detail} /> : null}
-      {tab === "code" ? <CodePromptView detail={detail} attempt={attempt} /> : null}
-    </div>
-  </aside>;
-}
-
-function LoadingState() {
-  return <main className="center-state"><LoaderCircle className="spin" /><h1>正在读取真实 E2E 持久化数据</h1><p>TaskLedger · Planner Trace · Worker Attempt</p></main>;
-}
-
-function ErrorState({ message, retry }: { message: string; retry: () => void }) {
-  return <main className="center-state"><h1>真实运行数据暂时不可读</h1><p>{message}</p><button onClick={retry}><RefreshCw size={15} />重试</button></main>;
-}
-
-export function MissionControl() {
-  const [runs, setRuns] = useState<RunSummary[]>([]);
-  const [selectedRunId, setSelectedRunId] = useState(900031);
-  const [view, setView] = useState<ExecutionView | null>(null);
-  const [selectedTurnId, setSelectedTurnId] = useState<string | null>(null);
-  const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
-  const [detail, setDetail] = useState<TaskDetail | null>(null);
-  const [detailLoading, setDetailLoading] = useState(false);
-  const [detailTab, setDetailTab] = useState<DetailTab>("lifecycle");
-  const [attemptId, setAttemptId] = useState<string | null>(null);
-  const [query, setQuery] = useState("");
-  const [loading, setLoading] = useState(true);
-  const [refreshing, setRefreshing] = useState(false);
-  const [error, setError] = useState<string | null>(null);
-
-  const loadExecution = useCallback(async (runId: number, quiet = false) => {
-    if (!quiet) setLoading(true);
-    else setRefreshing(true);
-    try {
-      const execution = await fetchExecution(runId);
-      setView(execution);
-      setSelectedTurnId((current) => current && execution.planner_turns.some((turn) => turn.turn_id === current) ? current : execution.planner_turns.at(-1)?.turn_id ?? null);
-      setError(null);
-    } catch (reason) {
-      setError(reason instanceof Error ? reason.message : String(reason));
-    } finally {
-      setLoading(false);
-      setRefreshing(false);
-    }
-  }, []);
-
-  useEffect(() => {
-    const controller = new AbortController();
-    fetchRuns(controller.signal).then((available) => {
-      setRuns(available);
-      const preferred = available.find((run) => run.script_build_id === 900031) ?? available[0];
-      if (!preferred) throw new Error("没有发现真实持久化 Run");
-      setSelectedRunId(preferred.script_build_id);
-      return loadExecution(preferred.script_build_id);
-    }).catch((reason) => {
-      if (reason instanceof DOMException && reason.name === "AbortError") return;
-      setError(reason instanceof Error ? reason.message : String(reason));
-      setLoading(false);
-    });
-    return () => controller.abort();
-  }, [loadExecution]);
-
-  useEffect(() => {
-    const timer = window.setInterval(() => void loadExecution(selectedRunId, true), 15_000);
-    return () => window.clearInterval(timer);
-  }, [loadExecution, selectedRunId]);
-
-  useEffect(() => {
-    if (!selectedTaskId) {
-      setDetail(null);
-      return;
-    }
-    const controller = new AbortController();
-    setDetailLoading(true);
-    fetchTaskDetail(selectedRunId, selectedTaskId, controller.signal).then((value) => {
-      setDetail(value);
-      setAttemptId(value.attempts.at(-1)?.attempt_id ?? null);
-    }).catch((reason) => {
-      if (!(reason instanceof DOMException && reason.name === "AbortError")) setError(reason instanceof Error ? reason.message : String(reason));
-    }).finally(() => setDetailLoading(false));
-    return () => controller.abort();
-  }, [selectedRunId, selectedTaskId]);
-
-  const changeRun = (id: number) => {
-    setSelectedRunId(id);
-    setSelectedTaskId(null);
-    setDetail(null);
-    setSelectedTurnId(null);
-    void loadExecution(id);
-  };
-  const selectedTurn = view?.planner_turns.find((turn) => turn.turn_id === selectedTurnId) ?? null;
-  if (loading && !view) return <LoadingState />;
-  if (error && !view) return <ErrorState message={error} retry={() => void loadExecution(selectedRunId)} />;
-  if (!view) return null;
-  return <main className={`execution-observer ${selectedTaskId ? "with-drawer" : ""}`}>
-    <Header runs={runs} view={view} selectedId={selectedRunId} onSelect={changeRun} onRefresh={() => void loadExecution(selectedRunId, true)} refreshing={refreshing} />
-    <div className="observer-grid">
-      <PlannerAxis turns={view.planner_turns} selectedId={selectedTurnId} tasks={view.tasks} onSelect={setSelectedTurnId} />
-      <TaskTreePanel view={view} turn={selectedTurn} query={query} onQuery={setQuery} selectedTaskId={selectedTaskId} onSelect={(task) => { setSelectedTaskId(task.task_id); setDetailTab("lifecycle"); }} />
-    </div>
-    <footer className="observer-foot"><span>{view.snapshot_basis}</span><span>root trace {shortId(view.run.root_trace_id)}</span><span>真实数据 · 只读</span></footer>
-    {selectedTaskId ? <TaskInspector detail={detail} loading={detailLoading} tab={detailTab} onTab={setDetailTab} attemptId={attemptId} onAttempt={setAttemptId} onClose={() => setSelectedTaskId(null)} /> : null}
-  </main>;
-}

+ 0 - 73
visualization/frontend/lib/task-tree.ts

@@ -1,73 +0,0 @@
-import type { TaskNode } from "@/lib/types";
-
-export interface TaskBranch {
-  task: TaskNode;
-  children: TaskBranch[];
-}
-
-function pathParts(path: string) {
-  return path.split(".").map((part) => Number.parseInt(part, 10));
-}
-
-export function compareTaskPath(left: TaskNode, right: TaskNode) {
-  const a = pathParts(left.display_path);
-  const b = pathParts(right.display_path);
-  const length = Math.max(a.length, b.length);
-  for (let index = 0; index < length; index += 1) {
-    const delta = (a[index] ?? -1) - (b[index] ?? -1);
-    if (delta !== 0) return delta;
-  }
-  return 0;
-}
-
-export function buildTaskForest(tasks: TaskNode[], visibleIds?: Set<string>): TaskBranch[] {
-  const visible = tasks.filter((task) => !visibleIds || visibleIds.has(task.task_id));
-  const byParent = new Map<string | null, TaskNode[]>();
-  for (const task of visible) {
-    const siblings = byParent.get(task.parent_task_id) ?? [];
-    siblings.push(task);
-    byParent.set(task.parent_task_id, siblings);
-  }
-  for (const siblings of byParent.values()) siblings.sort(compareTaskPath);
-  const walk = (task: TaskNode): TaskBranch => ({
-    task,
-    children: (byParent.get(task.task_id) ?? []).map(walk),
-  });
-  return (byParent.get(null) ?? []).map(walk);
-}
-
-export function taskKindLabel(kind: string) {
-  const labels: Record<string, string> = {
-    root: "创作总目标",
-    direction: "创作方向",
-    "pattern-retrieval": "模式检索",
-    "external-retrieval": "外部检索",
-    "candidate-portfolio": "脚本候选集",
-    compose: "整稿组合",
-    structure: "脚本结构",
-    paragraph: "脚本段落",
-    "element-set": "内容元素",
-    compare: "候选比较",
-  };
-  return labels[kind] ?? kind;
-}
-
-export function statusLabel(status: string) {
-  const labels: Record<string, string> = {
-    completed: "已验收",
-    waiting_children: "等待子任务",
-    needs_replan: "等待 Planner",
-    in_progress: "执行中",
-    pending: "待执行",
-    awaiting_validation: "等待验收",
-    awaiting_decision: "等待决策",
-    blocked: "阶段边界",
-    cancelled: "已取消",
-    failed: "失败",
-    stopped: "已停止",
-    submitted: "已提交",
-    passed: "通过",
-    accept: "采用",
-  };
-  return labels[status] ?? status;
-}

+ 4 - 91
visualization/frontend/package-lock.json

@@ -1,11 +1,11 @@
 {
-  "name": "script-build-mission-control",
+  "name": "script-build-journey",
   "version": "1.0.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "script-build-mission-control",
+      "name": "script-build-journey",
       "version": "1.0.0",
       "dependencies": {
         "lucide-react": "^0.468.0",
@@ -15,7 +15,6 @@
       },
       "devDependencies": {
         "@playwright/test": "^1.61.1",
-        "@testing-library/jest-dom": "^6.6.3",
         "@testing-library/react": "^16.1.0",
         "@types/node": "^22.10.2",
         "@types/react": "^19.0.1",
@@ -25,13 +24,6 @@
         "vitest": "^3.2.4"
       }
     },
-    "node_modules/@adobe/css-tools": {
-      "version": "4.5.0",
-      "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
-      "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@asamuzakjp/css-color": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
@@ -1653,33 +1645,6 @@
         "node": ">=18"
       }
     },
-    "node_modules/@testing-library/jest-dom": {
-      "version": "6.9.1",
-      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
-      "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@adobe/css-tools": "^4.4.0",
-        "aria-query": "^5.0.0",
-        "css.escape": "^1.5.1",
-        "dom-accessibility-api": "^0.6.3",
-        "picocolors": "^1.1.1",
-        "redent": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=14",
-        "npm": ">=6",
-        "yarn": ">=1"
-      }
-    },
-    "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
-      "version": "0.6.3",
-      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
-      "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/@testing-library/react": {
       "version": "16.3.2",
       "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
@@ -1927,6 +1892,7 @@
       "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
       "dev": true,
       "license": "Apache-2.0",
+      "peer": true,
       "dependencies": {
         "dequal": "^2.0.3"
       }
@@ -2038,13 +2004,6 @@
         "node": ">= 0.8"
       }
     },
-    "node_modules/css.escape": {
-      "version": "1.5.1",
-      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
-      "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/cssstyle": {
       "version": "4.6.0",
       "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
@@ -2138,6 +2097,7 @@
       "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
       "dev": true,
       "license": "MIT",
+      "peer": true,
       "engines": {
         "node": ">=6"
       }
@@ -2514,16 +2474,6 @@
         "node": ">=0.10.0"
       }
     },
-    "node_modules/indent-string": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
-      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/is-potential-custom-element-name": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -2657,16 +2607,6 @@
         "node": ">= 0.6"
       }
     },
-    "node_modules/min-indent": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
-      "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">=4"
-      }
-    },
     "node_modules/ms": {
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2929,20 +2869,6 @@
       "license": "MIT",
       "peer": true
     },
-    "node_modules/redent": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
-      "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "indent-string": "^4.0.0",
-        "strip-indent": "^3.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/rollup": {
       "version": "4.62.2",
       "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -3109,19 +3035,6 @@
       "dev": true,
       "license": "MIT"
     },
-    "node_modules/strip-indent": {
-      "version": "3.0.0",
-      "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
-      "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "min-indent": "^1.0.0"
-      },
-      "engines": {
-        "node": ">=8"
-      }
-    },
     "node_modules/strip-literal": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",

+ 1 - 2
visualization/frontend/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "script-build-mission-control",
+  "name": "script-build-journey",
   "version": "1.0.0",
   "private": true,
   "scripts": {
@@ -21,7 +21,6 @@
   },
   "devDependencies": {
     "@playwright/test": "^1.61.1",
-    "@testing-library/jest-dom": "^6.6.3",
     "@testing-library/react": "^16.1.0",
     "@types/node": "^22.10.2",
     "@types/react": "^19.0.1",

+ 1 - 1
visualization/frontend/playwright.config.ts

@@ -4,7 +4,7 @@ export default defineConfig({
   testDir: "./tests/e2e",
   timeout: 30_000,
   use: {
-    baseURL: "http://127.0.0.1:3008",
+    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3008",
     viewport: { width: 1600, height: 1000 },
     screenshot: "only-on-failure",
     trace: "retain-on-failure",

+ 65 - 0
visualization/frontend/tests/e2e/journey.spec.ts

@@ -0,0 +1,65 @@
+import { expect, test } from "@playwright/test";
+
+const baseStep = {
+  sequence: 1,
+  status: "completed",
+  task_id: "task-structure",
+  attempt_id: null,
+  validation_id: null,
+  decision_id: null,
+  reasoning_source: "contract",
+  process: { label: "创建整体结构 Task", actor: "Planner", tool_name: "plan_script_tasks", trace_id: "root-42", details: {} },
+  input_data: [{ label: "已接受的创作方向", kind: "direction", ref: null, detail: null }],
+  output_data: [{ label: "TaskContract", kind: "contract", ref: null, detail: null }],
+  contract: { action: "创建整体结构 Task", reasoning: "根据任务合同创建结构", goals: ["goal-1", "goal-2"], inputs: ["已接受的创作方向"], output: "整体结构", criteria: ["结构完整", "覆盖所有目标"] },
+  parallel_group_id: null,
+  parallel_position: null,
+  parallel_total: null,
+  model: null,
+  tokens: null,
+  cost: null,
+  duration_ms: null,
+  created_at: "2026-07-21T01:00:00Z",
+  evidence: { contract: { task_kind: "structure", objective: "组织脚本结构", goal_ids: ["goal-1", "goal-2"] }, tool_calls: [], artifact_refs: [], validation: null, decision: null },
+};
+
+test("业务用户可从全局回合进入步骤和原始证据", async ({ page }) => {
+  await page.route(/\/api\/runs$/, (route) => route.fulfill({
+    headers: { "access-control-allow-origin": route.request().headers().origin ?? "http://127.0.0.1:3010", "access-control-allow-credentials": "true" },
+    json: [{ script_build_id: 42, status: "running", summary: "心理学反转脚本", started_at: null, completed_at: null }],
+  }));
+  await page.route(/\/api\/runs\/42\/journey$/, (route) => route.fulfill({
+    headers: { "access-control-allow-origin": route.request().headers().origin ?? "http://127.0.0.1:3010", "access-control-allow-credentials": "true" },
+    json: {
+      schema_version: "script-build-journey/v1",
+      generated_at: "2026-07-21T01:05:00Z",
+      script_build_id: 42,
+      root_trace_id: "root-42",
+      status: "running",
+      objective: "为「一次反转如何改变信念」创作可发布脚本",
+      input_summary: { topic: "一次反转如何改变信念", account_name: "每天心理学", persona_point_count: 18, strategy_names: ["先冲突后解法"], snapshot_id: "11", digest: "sha256:input" },
+      steps: [
+        { ...baseStep, sequence: 1, step_id: "plan:task-structure", step_type: "plan", title: "Planner 规划·整体结构", reasoning: "当前缺少整体结构,目标之间需要统一组织。" },
+        { ...baseStep, sequence: 2, step_id: "execute:attempt-1", step_type: "execute", title: "Worker 执行·整体结构", attempt_id: "attempt-1", reasoning: "先建立五段式骨架,再检查每个 Goal 的落点。", reasoning_source: "observable", process: { label: "执行 create_script_structure", actor: "Worker", tool_name: null, trace_id: "worker-1", details: {} }, output_data: [{ label: "Structure", kind: "structure", ref: "script-build://artifact-versions/11", detail: null }], contract: null, model: "qwen-plus", tokens: 2310, duration_ms: 18400, created_at: "2026-07-21T01:01:00Z", evidence: { contract: null, tool_calls: [{ name: "create_script_structure", arguments: {} }], artifact_refs: [{ uri: "script-build://artifact-versions/11", kind: "structure" }], validation: null, decision: null } },
+        { ...baseStep, sequence: 3, step_id: "validate:validation-1", step_type: "validate", title: "Validator 验收·整体结构", attempt_id: "attempt-1", validation_id: "validation-1", status: "failed", reasoning: "goal-2 的成功标准没有明确落点。", reasoning_source: "validation", process: { label: "独立 Validator 语义质检", actor: "Validator", tool_name: null, trace_id: "validator-1", details: {} }, input_data: [{ label: "Structure", kind: "structure", ref: "script-build://artifact-versions/11", detail: null }], output_data: [{ label: "Validation·failed", kind: "validation", ref: "validation-1", detail: null }], contract: null, created_at: "2026-07-21T01:02:00Z", evidence: { contract: null, tool_calls: [], artifact_refs: [], validation: { verdict: "failed", reason: "goal-2 缺少落点" }, decision: null } },
+        { ...baseStep, sequence: 4, step_id: "decide:decision-1", step_type: "decide", title: "Planner 决定·重新执行", attempt_id: "attempt-1", validation_id: "validation-1", decision_id: "decision-1", status: "retry", reasoning: "保留当前结构,补齐 goal-2 后再执行一次。", reasoning_source: "decision", process: { label: "保留任务合同,让 Worker 再执行一次", actor: "Planner", tool_name: "decide_script_task", trace_id: "root-42", details: {} }, input_data: [{ label: "Validation", kind: "validation", ref: "validation-1", detail: null }], output_data: [{ label: "重新执行", kind: "decision", ref: "decision-1", detail: null }], contract: null, created_at: "2026-07-21T01:03:00Z", evidence: { contract: null, tool_calls: [], artifact_refs: [], validation: null, decision: { action: "retry", reason: "补齐 goal-2" } } },
+      ],
+      edges: [
+        { edge_id: "loop:decide:decision-1:execute:attempt-1", source: "decide:decision-1", target: "execute:attempt-1", relation: "loop", label: "重试" },
+      ],
+      warnings: ["部分“思考”由冻结任务合同转述,不是隐藏思维链。"],
+    },
+  }));
+
+  await page.goto("/");
+  await expect(page.getByRole("heading", { name: "为「一次反转如何改变信念」创作可发布脚本" })).toBeVisible();
+  await expect(page.getByText("这次创作经过了 1 个任务回合")).toBeVisible();
+
+  await page.getByRole("button", { name: /当前缺少整体结构/ }).click();
+  await expect(page.getByRole("heading", { name: "Planner 规划·整体结构" })).toBeVisible();
+  await expect(page.getByText("重试·回到前面继续")).toBeVisible();
+
+  await page.getByRole("button", { name: "证据", exact: true }).click();
+  await expect(page.getByText("冻结的 TaskContract")).toBeVisible();
+  await page.screenshot({ path: "../test-artifacts/journey-v1.png", fullPage: true });
+});

+ 0 - 20
visualization/frontend/tests/e2e/real-execution.spec.ts

@@ -1,20 +0,0 @@
-import { expect, test } from "@playwright/test";
-
-test("projects real run 900031 into planner axis and task tree", async ({ page }) => {
-  await page.goto("/");
-  await expect(page.getByText("REAL EXECUTION OBSERVER", { exact: true })).toBeVisible();
-  await expect(page.getByRole("heading", { name: "全局 Planner 在树上移动焦点" })).toBeVisible();
-  await expect(page.getByRole("heading", { name: "围绕创作总目标动态生长" })).toBeVisible();
-  await expect(page.locator(".task-record")).toHaveCount(8);
-  await expect(page.getByText("真实数据 · 只读", { exact: true })).toBeVisible();
-});
-
-test("switches to real run 900029 and opens a worker loop", async ({ page }) => {
-  await page.goto("/");
-  await page.getByRole("combobox", { name: "真实 Script Build" }).selectOption("900029");
-  await expect(page.locator(".task-record")).toHaveCount(11);
-  await page.getByRole("button", { name: /0\.2\.1\.2 脚本段落/ }).click();
-  await page.getByRole("button", { name: "Worker Loop" }).click();
-  await expect(page.locator(".worker-loop")).toBeVisible();
-  await expect(page.locator(".worker-loop")).toContainText("读取数据");
-});

+ 0 - 21
visualization/frontend/tests/task-tree.test.ts

@@ -1,21 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { buildTaskForest } from "@/lib/task-tree";
-import type { TaskNode } from "@/lib/types";
-
-function task(task_id: string, display_path: string, parent_task_id: string | null): TaskNode {
-  return { task_id, display_path, parent_task_id, child_task_ids: [], task_kind: "task", phase: "phase-two", objective: task_id, status: "pending", blocked_reason: null, attempt_count: 0, validation_count: 0, decision_count: 0, current_spec_version: 1, acceptance_criteria: [], context_refs: [], created_at: "", updated_at: "" };
-}
-
-describe("buildTaskForest", () => {
-  it("keeps numeric task paths ordered and nests children", () => {
-    const forest = buildTaskForest([task("p2", "0.2", "root"), task("root", "0", null), task("p1", "0.1", "root"), task("leaf", "0.1.1", "p1")]);
-    expect(forest[0].task.task_id).toBe("root");
-    expect(forest[0].children.map((item) => item.task.task_id)).toEqual(["p1", "p2"]);
-    expect(forest[0].children[0].children[0].task.task_id).toBe("leaf");
-  });
-
-  it("can replay only the ids visible at one planner turn", () => {
-    const forest = buildTaskForest([task("root", "0", null), task("p1", "0.1", "root"), task("future", "0.2", "root")], new Set(["root", "p1"]));
-    expect(forest[0].children.map((item) => item.task.task_id)).toEqual(["p1"]);
-  });
-});

+ 2 - 0
visualization/frontend/tsconfig.json

@@ -5,6 +5,8 @@
     "allowJs": false,
     "skipLibCheck": true,
     "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
     "noEmit": true,
     "esModuleInterop": true,
     "module": "esnext",

+ 1 - 0
visualization/frontend/vitest.config.ts

@@ -2,6 +2,7 @@ import { configDefaults, defineConfig } from "vitest/config";
 import path from "node:path";
 
 export default defineConfig({
+  esbuild: { jsx: "automatic" },
   resolve: { alias: { "@": path.resolve(__dirname, ".") } },
   test: {
     environment: "jsdom",