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:
co-authored by
Claude Opus 5
parent
3c974d2860
commit
a919d2e961
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user