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:
112
apps/web/lib/chain/chain.test.ts
Normal file
112
apps/web/lib/chain/chain.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { emptyDecisions, setNote, setVerdict } from "@/lib/review/decisions";
|
||||
import type { JobView } from "@/lib/jobs/job";
|
||||
import {
|
||||
buildResumeRequest,
|
||||
chainPhase,
|
||||
chainResultView,
|
||||
} from "./chain";
|
||||
|
||||
function job(result: Record<string, unknown> | null): JobView {
|
||||
return {
|
||||
id: "j1",
|
||||
kind: "chain",
|
||||
status: "running",
|
||||
progress: 0,
|
||||
result,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("chainResultView", () => {
|
||||
it("narrows a completed chain result", () => {
|
||||
const view = chainResultView(
|
||||
job({
|
||||
chain_key: "draft_volume",
|
||||
written: [1, 2, 3],
|
||||
completed: true,
|
||||
awaiting_chapter: null,
|
||||
}),
|
||||
);
|
||||
expect(view).toEqual({
|
||||
chainKey: "draft_volume",
|
||||
written: [1, 2, 3],
|
||||
awaitingChapter: null,
|
||||
completed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("narrows an awaiting result (interrupt on a chapter)", () => {
|
||||
const view = chainResultView(
|
||||
job({
|
||||
chain_key: "draft_volume",
|
||||
written: [1],
|
||||
completed: false,
|
||||
awaiting_chapter: 2,
|
||||
}),
|
||||
);
|
||||
expect(view.awaitingChapter).toBe(2);
|
||||
expect(view.completed).toBe(false);
|
||||
expect(view.written).toEqual([1]);
|
||||
});
|
||||
|
||||
it("returns safe defaults for null/garbage result", () => {
|
||||
expect(chainResultView(job(null))).toEqual({
|
||||
chainKey: null,
|
||||
written: [],
|
||||
awaitingChapter: null,
|
||||
completed: false,
|
||||
});
|
||||
expect(
|
||||
chainResultView(job({ written: "nope", awaiting_chapter: "x" })).written,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps only finite integer chapter numbers in written", () => {
|
||||
const view = chainResultView(
|
||||
job({ written: [1, "2", 3.5, null, 4] }),
|
||||
);
|
||||
expect(view.written).toEqual([1, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chainPhase", () => {
|
||||
it("maps polling with no awaiting chapter to running", () => {
|
||||
expect(chainPhase("polling", null)).toBe("running");
|
||||
});
|
||||
|
||||
it("maps any awaiting chapter to awaiting (interrupt signal is authoritative)", () => {
|
||||
// 后端 interrupt 时置 status=awaiting_input(被 narrowJob 抹平成 queued→仍 polling),
|
||||
// 但 result.awaiting_chapter 已置数;运行中该值恒 null,故它是暂停的权威信号。
|
||||
expect(chainPhase("polling", 2)).toBe("awaiting");
|
||||
expect(chainPhase("done", 2)).toBe("awaiting");
|
||||
});
|
||||
|
||||
it("maps done + no awaiting to completed", () => {
|
||||
expect(chainPhase("done", null)).toBe("completed");
|
||||
});
|
||||
|
||||
it("maps error to failed (even if an awaiting chapter lingers)", () => {
|
||||
expect(chainPhase("error", null)).toBe("failed");
|
||||
expect(chainPhase("error", 2)).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResumeRequest", () => {
|
||||
it("includes only resolved decisions and trims notes (reuses accept decision logic)", () => {
|
||||
let drafts = emptyDecisions(3);
|
||||
drafts = setVerdict(drafts, 0, "accept");
|
||||
drafts = setVerdict(drafts, 2, "manual");
|
||||
drafts = setNote(drafts, 2, " 改成后天淬炼 ");
|
||||
const req = buildResumeRequest(drafts);
|
||||
expect(req.decisions).toEqual([
|
||||
{ conflict_index: 0, verdict: "accept" },
|
||||
{ conflict_index: 2, verdict: "manual", note: "改成后天淬炼" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty drafts → empty decisions list", () => {
|
||||
expect(buildResumeRequest([])).toEqual({ decisions: [] });
|
||||
});
|
||||
});
|
||||
67
apps/web/lib/chain/chain.ts
Normal file
67
apps/web/lib/chain/chain.ts
Normal 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 的裁决组装逻辑(DRY;resume.decisions 与
|
||||
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text(链续跑不带终稿)。
|
||||
export function buildResumeRequest(
|
||||
drafts: readonly DecisionDraft[],
|
||||
): ChainResumeRequest {
|
||||
const { decisions } = buildAcceptRequest("", drafts);
|
||||
return { decisions };
|
||||
}
|
||||
Reference in New Issue
Block a user