feat(maf): unified safe display + decision lock for approvals

Stage-6 MAF frontend foundation plus P1 approval safety hardening:

- Add lib/safe-display.ts: single-source safeDisplayJson that deletes
  sensitive keys (never renders as ***), recurses with depth/array/field/
  string caps, returns hiddenCount + truncated.
- MafApprovalCard & MafActionAuditPanel both use safeDisplayJson; remove
  private sanitize duplicates. Panels hide entirely when nothing is left.
- Lock the card on both approve AND reject (resuming state) since the
  backend advances the workflow on either decision; add a decisionLockRef
  sync guard so a double-click before React re-render cannot fire a second
  mutate. Status text becomes '正在同步工作流…'.
- Stage-6 base shipped here as prerequisite: self-contained maf-flow-graph
  (no roundtable-planning dep), MafFlowGraphDialog/MafFactLedgerPanel/
  MafActionAuditPanel, shared risk-theme/action-audit/approval-state/
  run-resume libs, mergeBySeq export, canResume wiring, TButton icon fixes.
- Tests import real functions only; 48 pass (adds 8 safeDisplayJson cases).
This commit is contained in:
董宏凯 committed 2026-08-02 22:55:27 +08:00
1 parent 889a97a04d
commit df408831e5
17 files changed
+2060 -483

No files matched your search

@@ -0,0 +1,93 @@
/**
* 把 MafAction[] 按后端 status 归类成审计面板的可读分桶。
*
* 对齐后端 schemas.py 的 MafActionStatus 与文档约定的展示语义:
* pending / waiting_approval → 等待人工决定
* approved → 已批准,等待或正在恢复
* rejected / expired → 已拒绝或已过期,不会执行
* executed → 已执行
* failed → 已确认失败
* unknown → 结果未知,禁止自动重试
*
* 纯函数:输入 actions[],输出有序分桶(稳定排序,便于稳定渲染与单测)。
* 未知 status 落到「结果未知」桶(保守,不静默吞掉任何动作)。
*/
import type { MafAction } from "../api/types";
export type ActionBucketKey =
| "waiting"
| "approved"
| "rejected_or_expired"
| "executed"
| "failed"
| "unknown";
export interface ActionBucket {
key: ActionBucketKey;
/** 该桶的人类可读标题。 */
label: string;
/** 该桶的简短说明(展示在标题下方)。 */
hint: string;
items: MafAction[];
}
/** 后端 status → 桶 key。未知 status 落到 unknown(保守,不静默吞掉)。 */
export function classifyActionStatus(status: string | null | undefined): ActionBucketKey {
switch (status) {
case "pending":
case "waiting_approval":
return "waiting";
case "approved":
return "approved";
case "rejected":
case "expired":
return "rejected_or_expired";
case "executed":
return "executed";
case "failed":
return "failed";
case "unknown":
default:
return "unknown";
}
}
/** 桶的展示元信息(顺序即渲染顺序)。 */
export const ACTION_BUCKET_META: Record<ActionBucketKey, { label: string; hint: string }> = {
waiting: { label: "等待人工决定", hint: "pending / waiting_approval" },
approved: { label: "已批准,等待或正在恢复", hint: "approved" },
rejected_or_expired: { label: "已拒绝或已过期,不会执行", hint: "rejected / expired" },
executed: { label: "已执行", hint: "executed" },
failed: { label: "已确认失败", hint: "failed" },
unknown: { label: "结果未知,禁止自动重试", hint: "unknown" },
};
/** 桶顺序(渲染顺序)。 */
const BUCKET_ORDER: ActionBucketKey[] = [
"waiting",
"approved",
"executed",
"failed",
"rejected_or_expired",
"unknown",
];
/**
* 把 actions 按 status 归类成有序分桶。空桶也会返回(便于 UI 显示「0」计数),
* 调用方可自行决定是否隐藏空桶。
*/
export function classifyActionsByStatus(actions: MafAction[] | null | undefined): ActionBucket[] {
const byKey = new Map<ActionBucketKey, MafAction[]>();
for (const k of BUCKET_ORDER) byKey.set(k, []);
for (const a of actions ?? []) {
const key = classifyActionStatus(a.status);
byKey.get(key)!.push(a);
}
return BUCKET_ORDER.map((key) => ({
key,
label: ACTION_BUCKET_META[key].label,
hint: ACTION_BUCKET_META[key].hint,
items: byKey.get(key)!,
}));
}
@@ -0,0 +1,25 @@
/**
* MAF 审批卡片按钮的禁用判定 —— 纯函数。
*
* 审批卡片的「批准/拒绝」按钮必须在 mutation 进行中(pending)或已批准等待恢复(resuming)
* 时禁用,防止用户双击导致重复决策。把判定抽成纯函数,作为组件与测试的共同事实源:
* 组件 import 它设置 disabled,测试 import 它验证守卫,避免测试复制守卫逻辑、
* 组件改坏却仍通过。
*/
export interface DecisionButtonState {
/** mutation 是否在进行中(useMutation.isPending)。 */
pending: boolean;
/** 已批准、正在等待后端 dispatcher.resume() 自动恢复期间。 */
resuming: boolean;
}
/**
* 审批决策按钮是否应被禁用。
*
* 规则:pending 或 resuming 任一为 true → 禁用(防重复提交 + 防恢复期间二次点击)。
* 这是「pending 审批双击只发一次决策」的唯一事实源。
*/
export function isDecisionButtonDisabled(state: DecisionButtonState): boolean {
return state.pending || state.resuming;
}
@@ -1,139 +0,0 @@
/**
* 把 MAF 事实账本 + 末轮结论转成 FlowGraphPanel 可渲染的 FlowGraphData。
*
* 两个数据源,优先级合并:
* 1. `final_result.content` 里的 ```flow-json``` 块(次源,结论流程图)—— 若 final_result
* 含 flow-json,用现成的 extractFlowGraph 解析,直接作为「结论流程图」返回(它已是
* 模型精心编排的层级图,比纯事实更有结构)。
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable);
* 证据根用 start。
*
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 FlowGraphData | null。
* null 表示无任何可绘制数据,由组件显示空态。
*/
import {
extractFlowGraph,
type FlowGraphData,
type FlowGraphEdge,
type FlowGraphNode,
} from "@/roundtable-planning/lib/flow-graph";
import type { MafCheckpoint, MafFactLedger } from "../api/types";
/** 截断长文本用于节点 label(detail 保留全文)。 */
function truncate(text: string, max = 28): string {
const t = text.trim().replace(/\s+/g, " ");
return t.length <= max ? t : `${t.slice(0, max)}…`;
}
/**
* 尝试从 final_result.content 解析 flow-json 结论图。
* 有则优先返回(模型编排的结论图比纯事实更有结构)。
*/
export function extractConclusionFlow(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const content = checkpoint?.final_result?.content;
if (!content || typeof content !== "string") return null;
const graph = extractFlowGraph(content);
return graph && graph.nodes.length > 0 ? graph : null;
}
/**
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
*
* 布局:
* start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
*
* 每个事实节点 group 标 confirmed_by(席位),detail 是全文 + evidence_refs。
*/
export function buildFactFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
const confirmed = ledger?.confirmed_facts ?? [];
if (confirmed.length === 0) return null;
const nodes: FlowGraphNode[] = [];
const edges: FlowGraphEdge[] = [];
const nodeIds = new Set<string>();
const addNode = (n: FlowGraphNode) => {
if (!nodeIds.has(n.id)) {
nodeIds.add(n.id);
nodes.push(n);
}
};
const addEdge = (from: string, to: string, label?: string) => {
if (from === to) return;
edges.push({ from, to, label });
};
// 证据索引:ref → 来源类型,便于给节点配 type。
const evidenceByText = new Map<string, "evidence" | "tool">();
for (const e of ledger?.evidence ?? []) {
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
if (ref) evidenceByText.set(ref, "evidence");
}
for (const tr of ledger?.tool_results ?? []) {
if (tr.status !== "completed") continue; // 只有成功的工具算证据
const name = tr.tool_name ?? "";
const turn = tr.turn_index;
const ref =
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
if (ref) evidenceByText.set(ref, "tool");
}
// 根节点
const ROOT_ID = "root";
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
for (const fact of confirmed) {
const factId = `fact:${fact.candidate_id}`;
addNode({
id: factId,
label: truncate(fact.text),
type: "task",
detail: fact.text,
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
});
if ((fact.evidence_refs ?? []).length === 0) {
// 无证据引用的事实:直接连根(理论上 Phase-3 校验后不应出现,兜底)。
addEdge(ROOT_ID, factId);
continue;
}
for (const ref of fact.evidence_refs) {
const refStr = String(ref);
const evKind = evidenceByText.get(refStr) ?? "evidence";
const evId = `ev:${refStr}`;
// 证据节点(role=外部证据 / deliverable=工具产出)
addNode({
id: evId,
label: truncate(refStr.replace(/^tool:/, ""), 24),
type: evKind === "tool" ? "deliverable" : "role",
detail: refStr,
group: evKind === "tool" ? "工具证据" : "外部证据",
});
addEdge(ROOT_ID, evId);
addEdge(evId, factId);
}
}
// 边去重(同 from→to 只留一条)
const seen = new Set<string>();
const dedupEdges = edges.filter((e) => {
const k = `${e.from}->${e.to}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return { title: "事实证据图", nodes, edges: dedupEdges };
}
/**
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
*/
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): FlowGraphData | null {
return extractConclusionFlow(checkpoint) ?? buildFactFlowGraph(checkpoint);
}
@@ -0,0 +1,376 @@
/**
* MAF 事实/结论流程图 —— 纯函数层(MAF 自包含,不依赖任何旧会商目录)。
*
* 本文件是 MAF 模块独有的图数据层:定义自己的类型、自己解析 flow-json、自己构造
* confirmed_facts 证据图。不 import 任何旧会商目录的代码,保证新旧会商页面可独立演进。
*
* 两个数据源,优先级合并:
* 1. `final_result.content` 里的 ```flow-json 块(次源,结论流程图)—— 若 final_result
* 含 flow-json,解析后作为「结论流程图」返回(模型精心编排的层级图)。
* 2. `confirmed_facts`(主源,事实证据图)—— 每条已确认事实 → task 节点;其
* evidence_refs 连到对应证据节点(evidence → role / tool_results → deliverable)。
*
* 设计:**纯函数**,输入 checkpoint(宽松类型),输出 MafFlowGraphData | null。
* null 表示无任何可绘制数据,由组件显示空态。
*/
import type { MafCheckpoint, MafFactLedger } from "../api/types";
// ── 类型(MAF 独有,不复用旧会商)────────────────────────────────────────────
export type MafFlowNodeType =
| "start"
| "end"
| "stage"
| "task"
| "decision"
| "risk"
| "deliverable"
| "role";
export interface MafFlowGraphNode {
id: string;
label: string;
type: MafFlowNodeType;
detail?: string;
/** 层级/分组名(如 任务 / 目的 / 行为体),由模型在 flow-json 里给出。 */
group?: string;
}
export interface MafFlowGraphEdge {
from: string;
to: string;
label?: string;
}
export interface MafFlowGraphData {
title: string;
nodes: MafFlowGraphNode[];
edges: MafFlowGraphEdge[];
}
/** 节点类型 → 中文标签(图例 / 类型徽章)。 */
export const MAF_FLOW_NODE_TYPE_LABEL: Record<MafFlowNodeType, string> = {
start: "开始",
end: "结束",
stage: "阶段",
task: "任务",
decision: "判断",
risk: "风险",
deliverable: "交付物",
role: "角色",
};
const VALID_TYPES = new Set<string>(Object.keys(MAF_FLOW_NODE_TYPE_LABEL));
export const MAF_FLOW_FENCE_OPEN = "```flow-json";
// ── flow-json 解析(容错半截 JSON / 裸换行 / 尾逗号)─────────────────────────
function asString(v: unknown): string {
return typeof v === "string" ? v.trim() : "";
}
/**
* 容错解析单个对象切片:模型输出常带裸换行 / 尾逗号 / 中文标点导致整对象 JSON.parse
* 失败、节点被整个丢弃。这里用正则逐字段抽取,最大限度救回节点/边。
*/
function looseParseObject(slice: string): Record<string, unknown> | null {
const pickStr = (...keys: string[]): string | undefined => {
for (const k of keys) {
const m = new RegExp(`"${k}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(slice);
if (m) {
const raw = m[1];
try {
return JSON.parse(`"${raw.replace(/\r?\n/g, "\\n")}"`) as string;
} catch {
return raw.replace(/\\n/g, "\n").replace(/\\"/g, '"');
}
}
}
return undefined;
};
const obj: Record<string, unknown> = {};
const id = pickStr("id");
if (id) obj.id = id;
const label = pickStr("label", "name");
if (label) obj.label = label;
const type = pickStr("type");
if (type) obj.type = type;
const group = pickStr("group", "level", "tier", "layer");
if (group) obj.group = group;
const detail = pickStr("detail", "description");
if (detail) obj.detail = detail;
const toStr = pickStr("to", "target");
if (toStr) obj.to = toStr;
const fm = /"(?:from|prev)"\s*:\s*\[([\s\S]*?)\]/.exec(slice);
if (fm) {
const inner = fm[1];
const ids: unknown[] = [];
const objRe = /\{[^{}]*?"id"\s*:\s*"([^"]+)"[^{}]*?\}/g;
let om: RegExpExecArray | null;
while ((om = objRe.exec(inner)) !== null) ids.push({ id: om[1] });
if (ids.length === 0) {
const sRe = /"([^"]+)"/g;
let sm: RegExpExecArray | null;
while ((sm = sRe.exec(inner)) !== null) ids.push(sm[1]);
}
if (ids.length > 0) obj.from = ids;
} else {
const fromStr = pickStr("from", "source");
if (fromStr) obj.from = fromStr;
}
return Object.keys(obj).length > 0 ? obj : null;
}
/**
* 平衡括号扫描:从 `key`(如 `"nodes"`)后的 `[` 开始,把已完整闭合的顶层 `{…}`
* 逐个解析;跳过字符串内的括号与转义;数组未闭合(流式半截)时收集到哪算哪。
*/
function collectObjects(text: string, key: string): Record<string, unknown>[] {
const keyIdx = text.indexOf(key);
if (keyIdx < 0) return [];
const arrStart = text.indexOf("[", keyIdx);
if (arrStart < 0) return [];
const out: Record<string, unknown>[] = [];
let depth = 0;
let objStart = -1;
let inStr = false;
let esc = false;
for (let i = arrStart + 1; i < text.length; i++) {
const ch = text[i];
if (inStr) {
if (esc) esc = false;
else if (ch === "\\") esc = true;
else if (ch === '"') inStr = false;
continue;
}
if (ch === '"') {
inStr = true;
continue;
}
if (ch === "{") {
if (depth === 0) objStart = i;
depth++;
continue;
}
if (ch === "}") {
depth--;
if (depth === 0 && objStart >= 0) {
const objSlice = text.slice(objStart, i + 1);
let parsed: Record<string, unknown> | null = null;
try {
const j = JSON.parse(objSlice) as unknown;
if (j && typeof j === "object" && !Array.isArray(j)) {
parsed = j as Record<string, unknown>;
}
} catch {
parsed = looseParseObject(objSlice);
}
if (parsed) out.push(parsed);
objStart = -1;
}
continue;
}
if (ch === "]" && depth === 0) break;
}
return out;
}
function toNode(raw: Record<string, unknown>): MafFlowGraphNode | null {
const id = asString(raw.id);
const label = asString(raw.label) || asString(raw.name);
if (!id || !label) return null;
const rawType = asString(raw.type).toLowerCase();
const type = (VALID_TYPES.has(rawType) ? rawType : "task") as MafFlowNodeType;
const detail = asString(raw.detail) || asString(raw.description) || undefined;
const group =
asString(raw.group) || asString(raw.level) || asString(raw.tier) || asString(raw.layer) || undefined;
return { id, label, type, detail, group };
}
function edgesFromNode(raw: Record<string, unknown>, nodeId: string): MafFlowGraphEdge[] {
const from = raw.from ?? raw.prev;
if (!Array.isArray(from)) return [];
const out: MafFlowGraphEdge[] = [];
for (const entry of from) {
if (typeof entry === "string" && entry.trim()) {
out.push({ from: entry.trim(), to: nodeId });
} else if (entry && typeof entry === "object") {
const o = entry as Record<string, unknown>;
const fromId = asString(o.id) || asString(o.from);
if (!fromId) continue;
const label = asString(o.label) || undefined;
out.push({ from: fromId, to: nodeId, label });
}
}
return out;
}
function toEdge(raw: Record<string, unknown>): MafFlowGraphEdge | null {
const from = asString(raw.from) || asString(raw.source);
const to = asString(raw.to) || asString(raw.target);
if (!from || !to) return null;
const label = asString(raw.label) || undefined;
return { from, to, label };
}
/**
* 从(可能流式半截的)回复文本里提取最后一个 ```flow-json 块并解析。
* 没有 fence 或一个完整节点都没有 → null。
*/
export function extractMafFlowGraph(text: string): MafFlowGraphData | null {
const start = text.lastIndexOf(MAF_FLOW_FENCE_OPEN);
if (start < 0) return null;
let body = text.slice(start + MAF_FLOW_FENCE_OPEN.length);
const close = body.indexOf("```");
if (close >= 0) body = body.slice(0, close);
const titleMatch = /"title"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(body);
let title = "";
if (titleMatch) {
try {
title = JSON.parse(`"${titleMatch[1]}"`) as string;
} catch {
title = titleMatch[1];
}
}
const rawNodes = collectObjects(body, '"nodes"');
const nodes: MafFlowGraphNode[] = [];
const seen = new Set<string>();
const edges: MafFlowGraphEdge[] = [];
for (const raw of rawNodes) {
const node = toNode(raw);
if (!node || seen.has(node.id)) continue;
seen.add(node.id);
nodes.push(node);
edges.push(...edgesFromNode(raw, node.id));
}
for (const raw of collectObjects(body, '"edges"')) {
const edge = toEdge(raw);
if (edge) edges.push(edge);
}
if (nodes.length === 0) return null;
const edgeKeys = new Set<string>();
const validEdges = edges.filter((e) => {
if (!seen.has(e.from) || !seen.has(e.to) || e.from === e.to) return false;
const key = `${e.from}->${e.to}`;
if (edgeKeys.has(key)) return false;
edgeKeys.add(key);
return true;
});
return { title, nodes, edges: validEdges };
}
// ── confirmed_facts 证据图 ──────────────────────────────────────────────────
/** 截断长文本用于节点 label(detail 保留全文)。 */
function truncate(text: string, max = 28): string {
const t = text.trim().replace(/\s+/g, " ");
return t.length <= max ? t : `${t.slice(0, max)}…`;
}
/**
* 尝试从 final_result.content 解析 flow-json 结论图。
* 有则优先返回(模型编排的结论图比纯事实更有结构)。
*/
export function extractConclusionFlow(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
const content = checkpoint?.final_result?.content;
if (!content || typeof content !== "string") return null;
const graph = extractMafFlowGraph(content);
return graph && graph.nodes.length > 0 ? graph : null;
}
/**
* 把 confirmed_facts + evidence + tool_results 编织成事实证据图。
*
* 布局:start(根) ──► evidence/tool(role/deliverable) ──► fact(task)
*/
export function buildMafFactFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
const ledger: MafFactLedger | undefined = checkpoint?.fact_ledger;
const confirmed = ledger?.confirmed_facts ?? [];
if (confirmed.length === 0) return null;
const nodes: MafFlowGraphNode[] = [];
const edges: MafFlowGraphEdge[] = [];
const nodeIds = new Set<string>();
const addNode = (n: MafFlowGraphNode) => {
if (!nodeIds.has(n.id)) {
nodeIds.add(n.id);
nodes.push(n);
}
};
const addEdge = (from: string, to: string, label?: string) => {
if (from === to) return;
edges.push({ from, to, label });
};
// 证据索引:ref → 来源类型。
const evidenceByText = new Map<string, "evidence" | "tool">();
for (const e of ledger?.evidence ?? []) {
const ref = typeof e.ref === "string" ? e.ref : typeof e.text === "string" ? e.text : "";
if (ref) evidenceByText.set(ref, "evidence");
}
for (const tr of ledger?.tool_results ?? []) {
if (tr.status !== "completed") continue;
const name = tr.tool_name ?? "";
const turn = tr.turn_index;
const ref =
name && turn != null ? `tool:${name}:${turn}` : name ? `tool:${name}` : "";
if (ref) evidenceByText.set(ref, "tool");
}
const ROOT_ID = "root";
addNode({ id: ROOT_ID, label: "已确认事实", type: "start" });
for (const fact of confirmed) {
const factId = `fact:${fact.candidate_id}`;
addNode({
id: factId,
label: truncate(fact.text),
type: "task",
detail: fact.text,
group: fact.confirmed_by ? `确认方:${fact.confirmed_by}` : "已确认",
});
if ((fact.evidence_refs ?? []).length === 0) {
addEdge(ROOT_ID, factId);
continue;
}
for (const ref of fact.evidence_refs) {
const refStr = String(ref);
const evKind = evidenceByText.get(refStr) ?? "evidence";
const evId = `ev:${refStr}`;
addNode({
id: evId,
label: truncate(refStr.replace(/^tool:/, ""), 24),
type: evKind === "tool" ? "deliverable" : "role",
detail: refStr,
group: evKind === "tool" ? "工具证据" : "外部证据",
});
addEdge(ROOT_ID, evId);
addEdge(evId, factId);
}
}
const seen = new Set<string>();
const dedupEdges = edges.filter((e) => {
const k = `${e.from}->${e.to}`;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
return { title: "事实证据图", nodes, edges: dedupEdges };
}
/**
* 合并两源:结论 flow-json 优先;否则用事实证据图;都没有 → null(空态)。
*/
export function buildMafFlowGraph(checkpoint: MafCheckpoint | null): MafFlowGraphData | null {
return extractConclusionFlow(checkpoint) ?? buildMafFactFlowGraph(checkpoint);
}
@@ -0,0 +1,49 @@
/**
* 工具风险等级 → TDesign Tag 主题/标签(MAF 审批卡 + 动作审计面板共用)。
*
* 后端实际使用 R0–R4(见 app/maf_roundtable 的工具元数据分类):
* R0/R1 → 低风险(success)
* R2 → 中风险(warning)
* R3/R4 → 高风险(danger)
* 兜底兼容旧的 low/medium/high 字面量;未登记的未知等级落到 default(保守展示)。
*
* 这是单一事实源:组件和测试都从这里 import,避免测试在文件里复制映射、
* 实现坏掉却仍通过。
*/
export type RiskTheme = "success" | "warning" | "danger" | "default";
const RISK_THEME_MAP: Record<string, RiskTheme> = {
R0: "success",
R1: "success",
R2: "warning",
R3: "danger",
R4: "danger",
// 兜底:后端可能返回旧值
low: "success",
medium: "warning",
high: "danger",
};
const RISK_LABEL_MAP: Record<string, string> = {
R0: "R0 · 低",
R1: "R1 · 低",
R2: "R2 · 中",
R3: "R3 · 高",
R4: "R4 · 高",
low: "low · 低",
medium: "medium · 中",
high: "high · 高",
};
/** 风险等级 → TDesign Tag 主题;未知等级落到 default(不放过任何未知风险)。 */
export function riskThemeFor(level: string | null | undefined): RiskTheme {
if (!level) return "default";
return RISK_THEME_MAP[level] ?? "default";
}
/** 风险等级 → 人类可读标签;未知等级原样返回(让审阅者看到原始值)。 */
export function riskLabelFor(level: string | null | undefined): string {
if (!level) return "—";
return RISK_LABEL_MAP[level] ?? level;
}
@@ -0,0 +1,45 @@
/**
* MAF run 手动恢复(resume)的前置判定 —— 纯函数。
*
* 后端契约(app/maf_roundtable):当 run 处于 waiting_approval 且仍存在 pending 审批时,
* 调用 POST /runs/{id}/resume 会返回 409。因此前端必须:
* 1. 仍有 pending 审批时,禁止手动恢复(让用户先处理审批);
* 2. 审批列表尚在加载时,禁止恢复(不知道是否还有 pending);
* 3. 审批清零且 run 仍为 waiting_approval 时(说明后端 dispatcher 自动恢复未生效或失败),
* 才允许手动恢复作为补偿操作。
*
* 单一事实源:MafRunHeader 与测试都从这里 import,避免测试复制判定逻辑。
*/
export interface CanManualResumeParams {
runStatus: string | null | undefined;
/** 审批列表是否仍在加载(首次或轮询中)。 */
approvalsLoading: boolean;
/** 审批列表是否成功加载(isLoading=false 且无 error)。 */
approvalsSuccess: boolean;
/** 当前 pending 审批数量。 */
pendingCount: number;
}
/**
* 是否允许手动恢复 run。
*
* 规则:
* - run 必须处于 waiting_approval(其它状态走各自的生命周期按钮);
* - 审批必须已成功加载(approvalsSuccess && !approvalsLoading);
* - 不存在任何 pending 审批(pendingCount === 0)。
*
* 满足以上三项 → true(显示「恢复」按钮作为补偿操作)。
*/
export function canManualResume({
runStatus,
approvalsLoading,
approvalsSuccess,
pendingCount,
}: CanManualResumeParams): boolean {
if (runStatus !== "waiting_approval") return false;
if (approvalsLoading) return false;
if (!approvalsSuccess) return false;
if (pendingCount > 0) return false;
return true;
}
@@ -0,0 +1,106 @@
/**
* MAF 安全展示 —— 统一的二次脱敏纯函数(审批卡 + 动作审计面板共用)。
*
* 设计:把「敏感键删除(不显示成 ***)+ 递归 + 深度/数组/字段/字符串上限」收敛成
* 单一事实源。审批卡和动作审计面板都 import safeDisplayJson,禁止各自再实现私有脱敏,
* 避免两边规则漂移(一边漏脱敏、另一边不漏)。
*
* 规则:
* - 敏感键直接删除,绝不渲染成 target_key: "***"(连键名都不能透露);
* - 键名忽略大小写匹配 SENSITIVE_KEY_RE;
* - 递归处理对象/数组,最大深度 4(>4 返回占位);
* - 单数组最多 20 项;单对象最多 30 个非敏感字段;
* - 单字符串最多 120 字;
* - 返回 hiddenCount / truncated,供页面显示「已隐藏 N 项敏感字段」「内容已截断」,
* 但绝不透露具体敏感字段名。
*/
const SENSITIVE_KEY_RE =
/(secret|token|password|passwd|credential|auth|cookie|target_key|idempotency|api_key|apikey|private_key)/i;
const MAX_DEPTH = 4;
const MAX_ARRAY_ITEMS = 20;
const MAX_OBJECT_FIELDS = 30;
const MAX_STRING_CHARS = 120;
export interface SafeDisplayResult {
/** 脱敏 + 截断后的 JSON 文本;无可展示内容时为空串(调用方据此隐藏整块面板)。 */
text: string;
/** 被删除的敏感字段总数(递归累计)。 */
hiddenCount: number;
/** 是否发生任何截断(深度/数组/字段数/字符串长度)。 */
truncated: boolean;
}
function isEmptyValue(v: unknown): boolean {
if (v == null) return true;
if (typeof v === "string") return v.length === 0;
if (Array.isArray(v)) return v.length === 0;
if (typeof v === "object") return Object.keys(v as Record<string, unknown>).length === 0;
return false;
}
/**
* 把任意值脱敏 + 截断后序列化成可展示 JSON 文本。
*
* 全部字段被敏感过滤清空(或输入本身为空)→ text="",调用方应隐藏对应面板。
*/
export function safeDisplayJson(value: unknown): SafeDisplayResult {
let hiddenCount = 0;
let truncated = false;
function sanitize(input: unknown, depth: number): unknown {
if (depth > MAX_DEPTH) {
truncated = true;
return "[内容层级过深]";
}
if (typeof input === "string") {
if (input.length > MAX_STRING_CHARS) {
truncated = true;
return `${input.slice(0, MAX_STRING_CHARS)}…`;
}
return input;
}
if (Array.isArray(input)) {
if (input.length > MAX_ARRAY_ITEMS) truncated = true;
return input.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitize(item, depth + 1));
}
if (input && typeof input === "object") {
const result: Record<string, unknown> = {};
let fieldCount = 0;
for (const [key, item] of Object.entries(input as Record<string, unknown>)) {
if (SENSITIVE_KEY_RE.test(key)) {
hiddenCount += 1;
continue;
}
if (fieldCount >= MAX_OBJECT_FIELDS) {
truncated = true;
break;
}
result[key] = sanitize(item, depth + 1);
fieldCount += 1;
}
return result;
}
return input;
}
const cleaned = sanitize(value, 0);
let text: string;
if (isEmptyValue(cleaned)) {
text = "";
} else {
try {
text = JSON.stringify(cleaned, null, 2);
} catch {
text = "[unserializable]";
}
}
return { text, hiddenCount, truncated };
}