需求 Harness 深度解析
Harness 把“应用能跑”提升为“业务要求可逐项验证”:需求可以自动派生,也允许人类锁定修改,结果则独立记录。
导入链与契约
前后端之间的线格式由两个纯序列化函数钉住,Requirement 保留人工所有权标志,Result 暴露证据、验证 SHA、观察路径和过期状态,使 UI 不需要理解 Prisma 枚举 对应源码落点是 src/lib/harness.ts:8、src/lib/harness.ts:9、src/lib/harness.ts:10、src/lib/harness.ts:13、src/lib/harness.ts:15、src/lib/harness.ts:19、src/lib/harness.ts:20。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
8 export function serializeRequirement(r: HarnessRequirement) {
9 return {
10 id: r.id,
11 excerpt: r.excerpt,
12 description: r.description,
13 type: r.type.toLowerCase(),
14 severity: r.severity.toLowerCase(),
15 locked: r.locked,
16 };
17 }
18
19 export function serializeResult(r: HarnessResult) {
20 return {
21 requirement_id: r.requirementId,
22 result: r.result,
23 evidence: r.evidence,
24 run_status: r.runStatus,
25 last_verified_sha: r.lastVerifiedSha,
26 watched_paths: r.watchedPaths,
27 run_at: r.runAt?.toISOString() ?? null,
28 is_stale: r.isStale,
29 };
核心实现,逐段拆
▸全量替换但不意外解锁
PUT 采用全量期望集合:缺失项删除,新项创建,已有项更新。输入先校验描述、类型和严重度,keptIds 决定删除边界,因此客户端必须提交完整集合 对应源码落点是 src/app/api/projects/[id]/harness/requirements/route.ts:26、src/app/api/projects/[id]/harness/requirements/route.ts:29、src/app/api/projects/[id]/harness/requirements/route.ts:39、src/app/api/projects/[id]/harness/requirements/route.ts:44、src/app/api/projects/[id]/harness/requirements/route.ts:51、src/app/api/projects/[id]/harness/requirements/route.ts:54、src/app/api/projects/[id]/harness/requirements/route.ts:62。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
62 const keptIds = incoming.filter((r) => r.id).map((r) => r.id as string);
63
64 const requirements = await prisma.$transaction(async (tx) => {
65 await tx.harnessRequirement.deleteMany({
66 where: { projectId: id, id: { notIn: keptIds } },
67 });
68 return Promise.all(
69 incoming.map((r) => {
70 const data = {
71 excerpt: r.excerpt?.trim() || null,
72 description: (r.description as string).trim(),
73 type: (r.type?.toUpperCase() ?? 'EXPLICIT') as RequirementType,
74 severity: (r.severity?.toUpperCase() ?? 'MEDIUM') as Severity,
75 };
76 // A create always claims manual ownership (the user typed it). An update
77 // only changes lock state when the client explicitly sends one — true for an
78 // edited row, false for a reset-to-auto; an omitted flag leaves the stored
79 // value untouched, so a partial payload can never mass-lock the whole set.
80 const lockedProvided = typeof r.locked === 'boolean';
81 return r.id
82 ? tx.harnessRequirement.update({
83 where: { id: r.id },
84 data: { ...data, ...(lockedProvided ? { locked: r.locked } : {}) },
85 })
86 : tx.harnessRequirement.create({
87 data: { id: ulid(), projectId: id, ...data, locked: r.locked ?? true },
88 });
89 })
90 );
91 });
92
93 return NextResponse.json({
94 requirements: requirements.map(serializeRequirement),
95 });
96 }
▸发布门禁不是简单总通过
UI 先把缺结果、INCONCLUSIVE、SUCCESS 和 FAIL 归一为四种 verdict,再按严重度计算 gate。Critical 失败直接禁止发布,普通失败或关键项未验证则带警告 对应源码落点是 src/components/blueprint/harness-slide.tsx:69、src/components/blueprint/harness-slide.tsx:70、src/components/blueprint/harness-slide.tsx:71、src/components/blueprint/harness-slide.tsx:75、src/components/blueprint/harness-slide.tsx:76、src/components/blueprint/harness-slide.tsx:84、src/components/blueprint/harness-slide.tsx:85。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
69 function verdictOf(result: Result | undefined): Verdict {
70 if (!result || !result.run_at) return 'unverified';
71 if (result.run_status === 'INCONCLUSIVE') return 'inconclusive';
72 return result.result === 'SUCCESS' ? 'pass' : 'fail';
73 }
74
75 function computeGate(requirements: Requirement[], verdicts: Map<string, Verdict>) {
76 if (requirements.length === 0) {
77 return {
78 label: 'No requirements yet',
79 reason: 'Add business requirements to gate this app on.',
80 grad: 'from-slate-500 to-slate-700',
81 Icon: Question,
82 };
83 }
84 const criticals = requirements.filter((r) => r.severity === 'critical');
85 const anyCriticalFail = criticals.some((r) => verdicts.get(r.id) === 'fail');
86 const anyFail = requirements.some((r) => verdicts.get(r.id) === 'fail');
87 const allCriticalPass = criticals.every((r) => verdicts.get(r.id) === 'pass');
88
89 if (anyCriticalFail) {
90 return {
91 label: 'Do not ship',
92 reason: 'A critical requirement is failing.',
93 grad: 'from-red-500 to-rose-700',
94 Icon: Prohibit,
95 };
96 }
97 if (anyFail || !allCriticalPass) {
98 return {
99 label: 'Ship with warnings',
100 reason: anyFail
101 ? 'Non-critical requirements are failing.'
102 : 'Critical requirements are not verified yet.',
103 grad: 'from-amber-500 to-orange-600',
104 Icon: Warning,
105 };
106 }
107 return {
108 label: 'Safe to ship',
109 reason: 'Every critical requirement passes.',
110 grad: 'from-emerald-500 to-green-700',
111 Icon: RocketLaunch,
112 };
113 }
关键流程
run request → read-only audit → result → gate
运行按钮只写持久请求标志并返回 202;如果已经运行就不重复请求。这让按钮动作能够跨 Worker 调度延迟生存,同时保持幂等,最终结果仍从共享 SSE 返回 对应源码落点是 src/app/api/projects/[id]/harness/run/route.ts:4、src/app/api/projects/[id]/harness/run/route.ts:7、src/app/api/projects/[id]/harness/run/route.ts:9、src/app/api/projects/[id]/harness/run/route.ts:14、src/app/api/projects/[id]/harness/run/route.ts:18、src/app/api/projects/[id]/harness/run/route.ts:22、src/app/api/projects/[id]/harness/run/route.ts:23。把这些相邻行放在同一条执行链里看,可以确认它不是文档里的愿景描述,而是已经落到请求、状态和界面反馈中的实现;同时也能辨认出失败时由哪一层收敛、下一次刷新从何处恢复,以及扩容后最先需要替换的边界。
边界 / 失败模式
- 全量 PUT 要求客户端处理并发编辑,否则旧快照可能覆盖新修改。
- INCONCLUSIVE 不等于 FAIL,门禁会保持未验证语义。
- 人工 locked 需求只有显式 reset 才交回自动派生。