M5(1/3): process 的 domain —— 闭合校验、四策略解析、状态聚合

- checkClosure:七类违规(边未映射 / 必填未齐 / 类型冲突 / 孤立节点 /
  网关不平衡 / 汇合来源不明 / 容忍分支被必填依赖)+ 活动未安装 + 岗位不存在;
  不闭合不得发布 —— 画布即时校验与后端裁决同源
- resolveOwner:四策略封闭(指定人 / 岗位唯一人 / 轮值 / 来源跟随);
  岗上多人退轮值,并列按 staffId 稳定排序 —— 解析必须确定性
- aggregateWorkflowState:聚合投影,流程层不另设状态机;计量对象是节点实例,
  XOR 未选分支不入集合,改道/容忍/升审批已消化即不入异常
- nextInstanceNumber:流级重做重开新实例,不复活旧任务

325 个测试通过,门禁五道全绿

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 01:55:47 +08:00
co-authored by Claude Opus 5
parent 3c974d2860
commit a919d2e961
8 changed files with 769 additions and 2 deletions
@@ -0,0 +1,218 @@
/**
* 边映射闭合校验 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03)。
*
* 「**连线即映射,画图即校验,不闭合不得发布。**
* 连一条边即须完成该边映射(必填未映射 = 边未完成),类型兼容依两端契约 schema 当场校验;
* 存在未完成边、未满足必填或类型冲突的流程包**可存草稿、不得发布**。
* AND 汇合后的同名字段必须**显式指定来源分支**,不做隐式合并。」
*
* 前端画布的即时校验与后端裁决**同源** —— 这一份实现两边共用。
*/
import type { ClosureReport, ClosureViolation, Edge, ProcessNode, ProcessPackage } from '@contracts';
/** 活动契约的必填面 —— 由编排器从活动库取得 */
export type ActivityContractFace = {
readonly activityId: string;
/** data 的必填字段路径 */
readonly requiredInputs: readonly string[];
/** 字段 → 类型(JSON Schema 的 type) */
readonly inputTypes: Readonly<Record<string, string>>;
/** 输出口:口名 → 载荷字段类型 */
readonly outputs: Readonly<Record<string, Readonly<Record<string, string>>>>;
};
export type ClosureInput = {
readonly pkg: ProcessPackage;
/** 已安装活动的契约面;缺失即 unknown-activity */
readonly contracts: ReadonlyMap<string, ActivityContractFace>;
/** 已存在的岗位编码 */
readonly positions: ReadonlySet<string>;
};
export function checkClosure(input: ClosureInput): ClosureReport {
const { pkg, contracts, positions } = input;
const violations: ClosureViolation[] = [];
const byId = new Map(pkg.nodes.map((n) => [n.id, n]));
const incoming = new Map<string, Edge[]>();
const outgoing = new Map<string, Edge[]>();
for (const edge of pkg.edges) {
(incoming.get(edge.to) ?? incoming.set(edge.to, []).get(edge.to)!).push(edge);
(outgoing.get(edge.from) ?? outgoing.set(edge.from, []).get(edge.from)!).push(edge);
if (!byId.has(edge.from) || !byId.has(edge.to)) {
violations.push({ kind: 'dangling-node', edgeId: edge.id, message: `边 ${edge.id} 指向不存在的节点` });
}
}
for (const node of pkg.nodes) {
// 孤立节点:除起点外都得有入边;除终点外都得有出边
if (node.kind !== 'start' && (incoming.get(node.id)?.length ?? 0) === 0) {
violations.push({ kind: 'dangling-node', nodeId: node.id, message: `节点「${node.label}」无入边` });
}
if (node.kind !== 'end' && (outgoing.get(node.id)?.length ?? 0) === 0) {
violations.push({ kind: 'dangling-node', nodeId: node.id, message: `节点「${node.label}」无出边` });
}
if (node.kind === 'activity') {
violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts, positions));
}
if (node.kind === 'approval' && !positions.has(node.approver.position)) {
violations.push({
kind: 'missing-owner-position',
nodeId: node.id,
message: `审批点「${node.label}」的岗位不存在:${node.approver.position}`,
});
}
if (node.kind === 'gateway') {
violations.push(...checkGateway(node, incoming.get(node.id) ?? [], outgoing.get(node.id) ?? []));
}
}
violations.push(...checkTolerateDependencies(pkg, contracts, byId));
return { publishable: violations.length === 0, violations };
}
function checkActivityNode(
node: Extract<ProcessNode, { kind: 'activity' }>,
inbound: readonly Edge[],
contracts: ReadonlyMap<string, ActivityContractFace>,
positions: ReadonlySet<string>,
): ClosureViolation[] {
const out: ClosureViolation[] = [];
const face = contracts.get(node.activityId);
if (face === undefined) {
out.push({
kind: 'unknown-activity',
nodeId: node.id,
message: `节点「${node.label}」引用的活动未安装:${node.activityId}`,
});
return out;
}
if (!positions.has(node.owner.position)) {
out.push({
kind: 'missing-owner-position',
nodeId: node.id,
message: `节点「${node.label}」的岗位不存在:${node.owner.position}`,
});
}
// 连一条边即须完成该边映射
for (const edge of inbound) {
if (edge.mapping.length === 0) {
out.push({ kind: 'edge-unmapped', edgeId: edge.id, nodeId: node.id, message: `边 ${edge.id} 未完成载荷映射` });
}
}
// 必填未映射 = 边未完成
const mapped = new Set(inbound.flatMap((e) => e.mapping.map((m) => m.target)));
for (const required of face.requiredInputs) {
if (!mapped.has(required)) {
out.push({
kind: 'required-unmapped',
nodeId: node.id,
field: required,
message: `节点「${node.label}」的必填字段未映射:${required}`,
});
}
}
// 类型兼容依两端契约 schema 当场校验
for (const edge of inbound) {
for (const mapping of edge.mapping) {
const targetType = face.inputTypes[mapping.target];
if (targetType === undefined || mapping.source.from !== 'output-port') continue;
const sourceType = contracts.get(node.activityId)?.outputs[mapping.source.port]?.[mapping.source.path];
if (sourceType !== undefined && sourceType !== targetType) {
out.push({
kind: 'type-conflict',
edgeId: edge.id,
field: mapping.target,
message: `边 ${edge.id} 字段 ${mapping.target} 类型冲突:${sourceType} → ${targetType}`,
});
}
}
}
// AND 汇合后的同名字段必须显式指定来源分支,不做隐式合并
const targets = inbound.flatMap((e) => e.mapping.map((m) => m.target));
const seen = new Set<string>();
for (const target of targets) {
if (seen.has(target) && inbound.length > 1) {
out.push({
kind: 'join-ambiguous-source',
nodeId: node.id,
field: target,
message: `汇合处字段 ${target} 有多个来源分支,须显式指定 —— 不做隐式合并`,
});
}
seen.add(target);
}
return out;
}
function checkGateway(
node: Extract<ProcessNode, { kind: 'gateway' }>,
inbound: readonly Edge[],
outbound: readonly Edge[],
): ClosureViolation[] {
const out: ClosureViolation[] = [];
if (node.mode === 'fork' && outbound.length < 2) {
out.push({ kind: 'gateway-unbalanced', nodeId: node.id, message: `分叉网关「${node.label}」出边少于两条` });
}
if (node.mode === 'join' && inbound.length < 2) {
out.push({ kind: 'gateway-unbalanced', nodeId: node.id, message: `汇合网关「${node.label}」入边少于两条` });
}
// XOR 的每条出边必须有条件(声明式、静态可检,不得内嵌自由逻辑)
if (node.gateway === 'exclusive' && node.mode === 'fork') {
for (const edge of outbound) {
if (edge.condition === undefined || edge.condition.trim() === '') {
out.push({ kind: 'edge-unmapped', edgeId: edge.id, nodeId: node.id, message: `异或网关的出边 ${edge.id} 缺条件` });
}
}
}
return out;
}
/**
* 容忍(仅限 AND 分支):静态校验其**下游映射不得依赖该分支的必填字段**(OS 03 失败走向)。
*/
function checkTolerateDependencies(
pkg: ProcessPackage,
contracts: ReadonlyMap<string, ActivityContractFace>,
byId: ReadonlyMap<string, ProcessNode>,
): ClosureViolation[] {
const out: ClosureViolation[] = [];
const tolerated = new Set(
pkg.nodes.filter((n) => n.kind === 'activity' && n.failure.kind === 'tolerate').map((n) => n.id),
);
if (tolerated.size === 0) return out;
for (const edge of pkg.edges) {
if (!tolerated.has(edge.from)) continue;
const target = byId.get(edge.to);
if (target?.kind !== 'activity') continue;
const face = contracts.get(target.activityId);
if (face === undefined) continue;
for (const mapping of edge.mapping) {
if (face.requiredInputs.includes(mapping.target)) {
out.push({
kind: 'tolerate-required-dependency',
edgeId: edge.id,
nodeId: target.id,
field: mapping.target,
message: `容忍分支 ${edge.from} 的输出被下游必填字段 ${mapping.target} 依赖 —— 汇合处必填映射不得依赖可空分支`,
});
}
}
}
return out;
}
/** 不闭合不得发布 —— 发布门的唯一判据 */
export function publishable(report: ClosureReport): boolean {
return report.publishable;
}
@@ -0,0 +1,137 @@
/**
* 负责人四策略解析与工作流状态聚合 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03 / 3.2)。
*/
import type { OwnerSpec, TerminalState, WorkflowState } from '@contracts';
/* ── 负责人四策略(OS 03) ────────────────────────────────────────────── */
export type Candidate = {
readonly staffId: string;
readonly name: string;
/** 轮值用:岗内在途任务数 */
readonly openTasks: number;
};
export type ResolveContext = {
readonly candidates: readonly Candidate[];
/** 来源跟随:同一工作流内该岗位已解析出的人 */
readonly alreadyResolved?: string | undefined;
};
export type Resolution =
| { readonly kind: 'resolved'; readonly staffId: string; readonly by: OwnerSpec['strategy']; readonly rationale: string }
/** 岗位空缺 —— 按节点的失败走向处置,或转治理台待指派 */
| { readonly kind: 'vacant'; readonly position: string };
/**
* 「编排声明岗位,起单时**确定性解析**。策略封闭四选;
* 默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,**结果与依据留痕**。」
*/
export function resolveOwner(spec: OwnerSpec, context: ResolveContext): Resolution {
const active = context.candidates;
switch (spec.strategy) {
case 'assigned': {
const staffId = spec.staffId;
if (staffId === undefined || !active.some((c) => c.staffId === staffId)) {
return { kind: 'vacant', position: spec.position };
}
return { kind: 'resolved', staffId, by: 'assigned', rationale: '编排直接绑定员工' };
}
case 'follow-source': {
// 来源跟随:复用同一工作流内该岗位已解析出的人,保持一单一人的连续
const existing = context.alreadyResolved;
if (existing !== undefined && active.some((c) => c.staffId === existing)) {
return { kind: 'resolved', staffId: existing, by: 'follow-source', rationale: '同流内该岗位已解析出的人' };
}
return fallbackToRotation(spec, active);
}
case 'rotation':
return fallbackToRotation(spec, active);
case 'sole': {
if (active.length === 0) return { kind: 'vacant', position: spec.position };
if (active.length === 1) {
return { kind: 'resolved', staffId: active[0]!.staffId, by: 'sole', rationale: '岗位唯一人' };
}
// 岗上多人时按轮值 —— 默认口径
return fallbackToRotation(spec, active);
}
}
}
/** 轮值:岗内以**在途任务数最少者**优先;并列时按 staffId 稳定排序,保证确定性 */
function fallbackToRotation(spec: OwnerSpec, candidates: readonly Candidate[]): Resolution {
if (candidates.length === 0) return { kind: 'vacant', position: spec.position };
const picked = [...candidates].sort(
(a, b) => a.openTasks - b.openTasks || (a.staffId < b.staffId ? -1 : a.staffId > b.staffId ? 1 : 0),
)[0]!;
return {
kind: 'resolved',
staffId: picked.staffId,
by: 'rotation',
rationale: `轮值:在途任务数 ${picked.openTasks} 最少`,
};
}
/* ── 工作流状态聚合(OS 3.2) ────────────────────────────────────────── */
/**
* 「工作流状态是**聚合投影** —— 由流内各任务的状态按固定规则聚合推导,
* 流程层**不另设状态机**。计量对象是**节点实例**(重开产生新实例,旧实例已终,不再入算)。」
*/
export type NodeInstance = {
readonly nodeId: string;
readonly instance: number;
readonly running: boolean;
/** 待人 / 监听中 */
readonly waiting: boolean;
readonly terminal?: TerminalState;
/** 非完成终态的失败走向是否已消化(改道 / 容忍 / 升审批一经生效即为消化) */
readonly failureDigested?: boolean;
/** XOR 未选分支从未激活 —— 不入集合 */
readonly activated: boolean;
};
export function aggregateWorkflowState(
instances: readonly NodeInstance[],
controlTerminated: boolean,
): WorkflowState {
if (controlTerminated) return 'terminated';
const live = instances.filter((i) => i.activated);
// 任一实例以非完成终态结束且其失败走向未消化 → 异常
const unresolvedFailure = live.some(
(i) => i.terminal !== undefined && i.terminal !== 'released' && i.failureDigested !== true,
);
if (unresolvedFailure) return 'abnormal';
if (live.some((i) => i.running)) return 'running';
if (live.some((i) => i.waiting)) return 'waiting';
// 全部已激活的节点实例收敛(完成或已消化)且无在途实例 → 已完成
const allConverged = live.every(
(i) => i.terminal === 'released' || (i.terminal !== undefined && i.failureDigested === true),
);
return allConverged && live.length > 0 ? 'completed' : 'waiting';
}
/* ── 失败走向的消化判定(OS 03) ────────────────────────────────────── */
export const FAILURE_DIGESTIONS = ['reroute', 'tolerate', 'escalate'] as const;
/** 改道、容忍与升审批一经生效即为**消化**,不入异常;终止流待人工处置中仍算异常 */
export function isDigested(disposition: 'terminate' | 'reroute' | 'escalate' | 'tolerate'): boolean {
return disposition !== 'terminate';
}
/**
* 「流程层**不自动重试**活动(过程内重试归 Harness);
* 流级重做一律是人裁决后**重开新任务实例** —— 同一节点、新任务 ID、留痕关联,不复活旧任务。」
*/
export function nextInstanceNumber(instances: readonly NodeInstance[], nodeId: string): number {
return instances.filter((i) => i.nodeId === nodeId).reduce((max, i) => Math.max(max, i.instance), 0) + 1;
}