// 多章工作流链前端纯逻辑(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"; // 内置链类型(对齐后端 SUPPORTED_CHAINS):draft_volume=从 start 章按记忆量产; // continue_volume=续写式(每章以上一章 accepted 正文末尾作前文引子,比纯 digest 更顺)。 export type ChainKind = "draft_volume" | "continue_volume"; export interface ChainKindOption { key: ChainKind; label: string; description: string; } export const CHAIN_KINDS: readonly ChainKindOption[] = [ { key: "draft_volume", label: "从头写", description: "按记忆与大纲量产,每章独立写。", }, { key: "continue_volume", label: "续写", description: "每章以上一章已验收正文末尾作前文引子,承接更顺。", }, ]; // 链 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 的裁决组装逻辑(DRY;resume.decisions 与 // accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text(链续跑不带终稿)。 export function buildResumeRequest( drafts: readonly DecisionDraft[], ): ChainResumeRequest { const { decisions } = buildAcceptRequest("", drafts); return { decisions }; }