feat(web): Scope B B1 — 多章工作流链前端 UI(发起/轮询/裁决续跑)

链页 app/projects/[id]/chains(RSC fetchProject)+ ChainPage/ChainStarter/
ChainProgress/ChainAdjudication;lib/chain 纯函数(result 收窄/相位映射/resume 组装)。
复用 useJobPoll 轮询、ConflictCard + decisions.ts 裁决、normalizeConflicts/latestReview、
friendlyError;nav 加 chains 入口。awaiting(interrupt)→ 读该章冲突渲 ConflictCard →
POST .../chains/runs/{job_id}/resume 续跑。gen:api 纳入 chains run/resume。
守 #5(链暂停=等裁决,token/正文不入 job result)。
This commit is contained in:
Yaojia Wang
2026-06-23 19:21:00 +02:00
parent 416caed56b
commit a2dcd89881
11 changed files with 874 additions and 4 deletions

View File

@@ -0,0 +1,67 @@
// 多章工作流链前端纯逻辑Scope B把 `GET /jobs/{id}` 的链 job result 安全收窄成
// 视图模型;把轮询状态 + interrupt 标志映射成链相位;裁决草稿 → resume 请求体。
// 纯逻辑(无 React / 无 fetch便于 node 环境单测。链走 job 轮询(非 SSE
import type { ChainResumeRequest } from "@/lib/api/types";
import type { JobView, PollStatus } from "@/lib/jobs/job";
import { buildAcceptRequest, type DecisionDraft } from "@/lib/review/decisions";
// 链 job result 的视图模型(与后端 `_chain_result` 字段对齐chain_key/written/completed/
// awaiting_chapter。token/正文绝不入 result不变量 #5 / chain §5故此处只有进度元数据。
export interface ChainResultView {
chainKey: string | null;
// 已写完(已验收/已晋升)的章号清单。
written: number[];
// interrupt 命中、等作者裁决的章号null = 未暂停(跑完或运行中)。
awaitingChapter: number | null;
// 全链跑完(无 interrupt 残留)。
completed: boolean;
}
function asIntList(v: unknown): number[] {
if (!Array.isArray(v)) return [];
return v.filter(
(n): n is number => typeof n === "number" && Number.isInteger(n),
);
}
function asIntOrNull(v: unknown): number | null {
return typeof v === "number" && Number.isInteger(v) ? v : null;
}
// 把链 job 的 result 收窄成 ChainResultView缺字段/脏数据给安全默认,守边界 #not-trust
export function chainResultView(job: JobView): ChainResultView {
const r = job.result ?? {};
return {
chainKey: typeof r["chain_key"] === "string" ? r["chain_key"] : null,
written: asIntList(r["written"]),
awaitingChapter: asIntOrNull(r["awaiting_chapter"]),
completed: r["completed"] === true,
};
}
// 链相位(驱动页面 UI 分支):运行中 / 待裁决 / 跑完 / 失败。
export type ChainPhase = "running" | "awaiting" | "completed" | "failed";
// 后端 interrupt 时把 job 置 awaiting_input非 done/failed+ result.awaiting_chapter=N——
// `narrowJob` 把未知态 awaiting_input 归到 "queued",故轮询 reducer 仍 polling运行中
// awaiting_chapter 恒为 null仅 interrupt 命中才置数,故以 `awaitingChapter !== null` 为
// 暂停的权威信号(不依赖被抹平的原始状态),让页面在暂停时进入裁决相而非空轮询(不变量 #5
export function chainPhase(
pollStatus: PollStatus,
awaitingChapter: number | null,
): ChainPhase {
if (pollStatus === "error") return "failed";
if (awaitingChapter !== null) return "awaiting";
if (pollStatus === "done") return "completed";
return "running";
}
// 裁决草稿 → resume 请求体。复用 accept 的裁决组装逻辑DRYresume.decisions 与
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text链续跑不带终稿
export function buildResumeRequest(
drafts: readonly DecisionDraft[],
): ChainResumeRequest {
const { decisions } = buildAcceptRequest("", drafts);
return { decisions };
}