Archived
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,31 @@
|
||||
# module: process
|
||||
|
||||
职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图即校验 · 不闭合不得发布)、
|
||||
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
|
||||
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
|
||||
画布的绘制(见 apps/web 的 gov-process)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`ClosureInput` `ClosureReport` `ClosureViolation` `ActivityContractFace`
|
||||
`Candidate` `Resolution` `NodeInstance` `WorkflowState`
|
||||
- 错误:`NotPublishableError` `PositionVacantError`
|
||||
- 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState`
|
||||
`isDigested` `nextInstanceNumber`
|
||||
|
||||
层级: L7(编码规范 §4)
|
||||
依赖: @contracts;task(L6)、@sfeel
|
||||
被依赖: trigger, app(治理台 · 工作流管理)
|
||||
|
||||
不变量(OS 03 / 3.2):
|
||||
- **不闭合不得发布**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、
|
||||
汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `publishable = false`
|
||||
- **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure`
|
||||
- **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕
|
||||
- **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**,
|
||||
重开产生新实例、旧实例不再入算;XOR 未选分支从未激活、不入集合
|
||||
- **失败走向的消化**:改道 / 容忍 / 升审批一经生效即消化;终止流待人工处置中算异常
|
||||
- **流级重做一律重开新实例** —— 同节点、新任务 ID、留痕关联,不复活旧任务
|
||||
|
||||
## M5 待补(docs/PROGRESS.md)
|
||||
port.ts 与 usecase:流程包的存取与发布、工作流实例的起单与流转
|
||||
(Mastra `createWorkflow` 编译在 `internal/adapter/workflow-compiler.ts`,编码规范 §6.1)。
|
||||
@@ -0,0 +1,293 @@
|
||||
/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { checkClosure } from '../internal/domain/closure.ts';
|
||||
import type { ActivityContractFace } from '../internal/domain/closure.ts';
|
||||
import {
|
||||
aggregateWorkflowState,
|
||||
isDigested,
|
||||
nextInstanceNumber,
|
||||
resolveOwner,
|
||||
} from '../internal/domain/owner-resolution.ts';
|
||||
import type { Candidate, NodeInstance } from '../internal/domain/owner-resolution.ts';
|
||||
|
||||
const face = (over: Partial<ActivityContractFace> = {}): ActivityContractFace => ({
|
||||
activityId: 'a4',
|
||||
requiredInputs: ['pn', 'basePrice'],
|
||||
inputTypes: { pn: 'string', basePrice: 'number' },
|
||||
outputs: { done: { pn: 'string', quote: 'number' } },
|
||||
...over,
|
||||
});
|
||||
|
||||
/** 统一报价的一小段:起点 → A4 成本定价 → 终点 */
|
||||
const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
|
||||
({
|
||||
id: 'quote' as never,
|
||||
name: '统一报价',
|
||||
version: 1,
|
||||
state: 'draft',
|
||||
callInterface: { input: {}, output: {} },
|
||||
nodes: [
|
||||
{ id: 'start' as never, kind: 'start', label: '起点' },
|
||||
{
|
||||
id: 'A4' as never,
|
||||
kind: 'activity',
|
||||
label: 'A4 成本定价',
|
||||
activityId: 'a4' as never,
|
||||
owner: { position: 'BD', strategy: 'sole' },
|
||||
failure: { kind: 'terminate' },
|
||||
},
|
||||
{ id: 'end' as never, kind: 'end', label: '终点' },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
from: 'start' as never,
|
||||
to: 'A4' as never,
|
||||
mapping: [
|
||||
{ target: 'pn', source: { from: 'initiation', path: 'pn' } },
|
||||
{ target: 'basePrice', source: { from: 'initiation', path: 'basePrice' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
],
|
||||
variables: [],
|
||||
...over,
|
||||
}) as ProcessPackage;
|
||||
|
||||
const contracts = new Map([['a4', face()]]);
|
||||
const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']);
|
||||
|
||||
describe('checkClosure —— 不闭合不得发布', () => {
|
||||
it('必填全映射、岗位存在即可发布', () => {
|
||||
const report = checkClosure({ pkg: pkg(), contracts, positions });
|
||||
expect(report.violations).toEqual([]);
|
||||
expect(report.publishable).toBe(true);
|
||||
});
|
||||
|
||||
it('边未完成映射即拦', () => {
|
||||
const broken = pkg({
|
||||
edges: [
|
||||
{ id: 'e1', from: 'start' as never, to: 'A4' as never, mapping: [], reopen: false },
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
],
|
||||
});
|
||||
const report = checkClosure({ pkg: broken, contracts, positions });
|
||||
expect(report.publishable).toBe(false);
|
||||
expect(report.violations.map((v) => v.kind)).toContain('edge-unmapped');
|
||||
expect(report.violations.map((v) => v.kind)).toContain('required-unmapped');
|
||||
});
|
||||
|
||||
it('必填未齐即拦', () => {
|
||||
const partial = pkg({
|
||||
edges: [
|
||||
{
|
||||
id: 'e1',
|
||||
from: 'start' as never,
|
||||
to: 'A4' as never,
|
||||
mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
],
|
||||
});
|
||||
const violations = checkClosure({ pkg: partial, contracts, positions }).violations;
|
||||
expect(violations.filter((v) => v.kind === 'required-unmapped').map((v) => v.field)).toEqual(['basePrice']);
|
||||
});
|
||||
|
||||
it('引用未安装的活动即拦', () => {
|
||||
const report = checkClosure({ pkg: pkg(), contracts: new Map(), positions });
|
||||
expect(report.violations.map((v) => v.kind)).toContain('unknown-activity');
|
||||
});
|
||||
|
||||
it('岗位不存在即拦', () => {
|
||||
const report = checkClosure({ pkg: pkg(), contracts, positions: new Set(['PM']) });
|
||||
expect(report.violations.map((v) => v.kind)).toContain('missing-owner-position');
|
||||
});
|
||||
|
||||
it('孤立节点即拦', () => {
|
||||
const orphan = pkg({
|
||||
nodes: [
|
||||
...pkg().nodes,
|
||||
{
|
||||
id: 'A9' as never,
|
||||
kind: 'activity',
|
||||
label: '游离节点',
|
||||
activityId: 'a4' as never,
|
||||
owner: { position: 'BD', strategy: 'sole' },
|
||||
failure: { kind: 'terminate' },
|
||||
},
|
||||
],
|
||||
});
|
||||
const violations = checkClosure({ pkg: orphan, contracts, positions }).violations;
|
||||
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('异或网关的出边必须有条件 —— 声明式、静态可检', () => {
|
||||
const withGateway = pkg({
|
||||
nodes: [
|
||||
...pkg().nodes,
|
||||
{ id: 'G2' as never, kind: 'gateway', label: 'G2 料号分流', gateway: 'exclusive', mode: 'fork' },
|
||||
],
|
||||
edges: [
|
||||
...pkg().edges,
|
||||
{ id: 'g1', from: 'A4' as never, to: 'G2' as never, mapping: [], reopen: false },
|
||||
{ id: 'g2', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
{ id: 'g3', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
],
|
||||
});
|
||||
const violations = checkClosure({ pkg: withGateway, contracts, positions }).violations;
|
||||
expect(violations.some((v) => v.message.includes('缺条件'))).toBe(true);
|
||||
});
|
||||
|
||||
it('容忍分支的输出不得被下游必填依赖', () => {
|
||||
const tolerant = pkg({
|
||||
nodes: [
|
||||
{ id: 'start' as never, kind: 'start', label: '起点' },
|
||||
{
|
||||
id: 'A3' as never,
|
||||
kind: 'activity',
|
||||
label: 'A3 渠道寻源',
|
||||
activityId: 'a4' as never,
|
||||
owner: { position: 'PRM', strategy: 'sole' },
|
||||
failure: { kind: 'tolerate' },
|
||||
},
|
||||
{
|
||||
id: 'A4' as never,
|
||||
kind: 'activity',
|
||||
label: 'A4 成本定价',
|
||||
activityId: 'a4' as never,
|
||||
owner: { position: 'BD', strategy: 'sole' },
|
||||
failure: { kind: 'terminate' },
|
||||
},
|
||||
{ id: 'end' as never, kind: 'end', label: '终点' },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e0', from: 'start' as never, to: 'A3' as never, mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }, { target: 'basePrice', source: { from: 'initiation', path: 'b' } }], reopen: false },
|
||||
{
|
||||
id: 'e1',
|
||||
from: 'A3' as never,
|
||||
to: 'A4' as never,
|
||||
mapping: [
|
||||
{ target: 'pn', source: { from: 'output-port', port: 'done', path: 'pn' } },
|
||||
{ target: 'basePrice', source: { from: 'output-port', port: 'done', path: 'quote' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false },
|
||||
],
|
||||
});
|
||||
const violations = checkClosure({ pkg: tolerant, contracts, positions }).violations;
|
||||
expect(violations.map((v) => v.kind)).toContain('tolerate-required-dependency');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveOwner —— 四策略确定性解析', () => {
|
||||
const many: Candidate[] = [
|
||||
{ staffId: 's2', name: '乙', openTasks: 3 },
|
||||
{ staffId: 's1', name: '甲', openTasks: 1 },
|
||||
{ staffId: 's3', name: '丙', openTasks: 1 },
|
||||
];
|
||||
|
||||
it('岗位唯一人', () => {
|
||||
const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [many[1]!] });
|
||||
expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'sole' });
|
||||
});
|
||||
|
||||
it('岗上多人时按轮值 —— 在途最少者优先', () => {
|
||||
const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: many });
|
||||
expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'rotation' });
|
||||
});
|
||||
|
||||
it('轮值并列时按 staffId 稳定排序 —— 解析必须确定性', () => {
|
||||
const a = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: many });
|
||||
const b = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: [...many].reverse() });
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('指定人:不在候选内即空缺', () => {
|
||||
expect(
|
||||
resolveOwner({ position: 'BD', strategy: 'assigned', staffId: 'sX' as never }, { candidates: many }),
|
||||
).toEqual({ kind: 'vacant', position: 'BD' });
|
||||
});
|
||||
|
||||
it('来源跟随:复用同流内已解析出的人', () => {
|
||||
const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many, alreadyResolved: 's2' });
|
||||
expect(r).toMatchObject({ kind: 'resolved', staffId: 's2', by: 'follow-source' });
|
||||
});
|
||||
|
||||
it('来源跟随:此前没解析过即退到轮值', () => {
|
||||
const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many });
|
||||
expect(r).toMatchObject({ by: 'rotation' });
|
||||
});
|
||||
|
||||
it('岗位空缺', () => {
|
||||
expect(resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [] })).toEqual({
|
||||
kind: 'vacant',
|
||||
position: 'BD',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateWorkflowState —— 聚合投影,流程层不另设状态机', () => {
|
||||
const inst = (over: Partial<NodeInstance> = {}): NodeInstance => ({
|
||||
nodeId: 'A1',
|
||||
instance: 1,
|
||||
running: false,
|
||||
waiting: false,
|
||||
activated: true,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('任一实例运行中 → 运行中', () => {
|
||||
expect(aggregateWorkflowState([inst({ running: true }), inst({ terminal: 'released' })], false)).toBe('running');
|
||||
});
|
||||
|
||||
it('无运行实例但有待人 / 监听 → 等待中', () => {
|
||||
expect(aggregateWorkflowState([inst({ waiting: true })], false)).toBe('waiting');
|
||||
});
|
||||
|
||||
it('全部收敛 → 已完成', () => {
|
||||
expect(aggregateWorkflowState([inst({ terminal: 'released' }), inst({ terminal: 'released' })], false)).toBe(
|
||||
'completed',
|
||||
);
|
||||
});
|
||||
|
||||
it('非完成终态且失败走向未消化 → 异常', () => {
|
||||
expect(aggregateWorkflowState([inst({ terminal: 'blocked' })], false)).toBe('abnormal');
|
||||
});
|
||||
|
||||
it('改道 / 容忍 / 升审批已消化即不入异常', () => {
|
||||
expect(aggregateWorkflowState([inst({ terminal: 'blocked', failureDigested: true })], false)).toBe('completed');
|
||||
});
|
||||
|
||||
it('XOR 未选分支从未激活,不入集合', () => {
|
||||
expect(
|
||||
aggregateWorkflowState([inst({ terminal: 'released' }), inst({ activated: false, terminal: 'blocked' })], false),
|
||||
).toBe('completed');
|
||||
});
|
||||
|
||||
it('控制事件终止 → 已终止', () => {
|
||||
expect(aggregateWorkflowState([inst({ running: true })], true)).toBe('terminated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('失败走向的消化与重开', () => {
|
||||
it('终止流待人工处置中仍算异常', () => {
|
||||
expect(isDigested('terminate')).toBe(false);
|
||||
expect(isDigested('reroute')).toBe(true);
|
||||
expect(isDigested('tolerate')).toBe(true);
|
||||
expect(isDigested('escalate')).toBe(true);
|
||||
});
|
||||
|
||||
it('重开产生新实例号 —— 不复活旧任务', () => {
|
||||
const instances: NodeInstance[] = [
|
||||
{ nodeId: 'A4', instance: 1, running: false, waiting: false, activated: true, terminal: 'released' },
|
||||
{ nodeId: 'A4', instance: 2, running: true, waiting: false, activated: true },
|
||||
{ nodeId: 'A6', instance: 1, running: true, waiting: false, activated: true },
|
||||
];
|
||||
expect(nextInstanceNumber(instances, 'A4')).toBe(3);
|
||||
expect(nextInstanceNumber(instances, 'A1')).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* module: process —— 对外类型(编码规范 §10.1;语义出处 OS 03)。
|
||||
*
|
||||
* 流程层:流程包定义与版本、**边映射闭合校验**、工作流实例
|
||||
* (网关 / 审批点 / 失败走向 / 负责人四策略 / 聚合)。
|
||||
*
|
||||
* M5 进行中:domain 已落(闭合校验 / 四策略解析 / 状态聚合),
|
||||
* 用例与 Mastra 工作流编译随后(编码规范 §6.1)。
|
||||
*/
|
||||
import type { ClosureReport, ClosureViolation, OwnerSpec, ProcessPackage, WorkflowState } from '@contracts';
|
||||
import type { ActivityContractFace, ClosureInput } from './internal/domain/closure.ts';
|
||||
import type { Candidate, NodeInstance, ResolveContext, Resolution } from './internal/domain/owner-resolution.ts';
|
||||
|
||||
export type {
|
||||
ActivityContractFace,
|
||||
Candidate,
|
||||
ClosureInput,
|
||||
ClosureReport,
|
||||
ClosureViolation,
|
||||
NodeInstance,
|
||||
OwnerSpec,
|
||||
ProcessPackage,
|
||||
ResolveContext,
|
||||
Resolution,
|
||||
WorkflowState,
|
||||
};
|
||||
|
||||
export class NotPublishableError extends Error {
|
||||
constructor(readonly report: ClosureReport) {
|
||||
super(`流程包不闭合,不得发布:${report.violations.map((v) => v.message).join('; ')}`);
|
||||
this.name = 'NotPublishableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PositionVacantError extends Error {
|
||||
constructor(readonly position: string) {
|
||||
super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`);
|
||||
this.name = 'PositionVacantError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
|
||||
*/
|
||||
export type {
|
||||
ActivityContractFace,
|
||||
Candidate,
|
||||
ClosureInput,
|
||||
ClosureReport,
|
||||
ClosureViolation,
|
||||
NodeInstance,
|
||||
OwnerSpec,
|
||||
ProcessPackage,
|
||||
ResolveContext,
|
||||
Resolution,
|
||||
WorkflowState,
|
||||
} from './contract.ts';
|
||||
export { NotPublishableError, PositionVacantError } from './contract.ts';
|
||||
|
||||
export { checkClosure, publishable } from './internal/domain/closure.ts';
|
||||
export {
|
||||
aggregateWorkflowState,
|
||||
FAILURE_DIGESTIONS,
|
||||
isDigested,
|
||||
nextInstanceNumber,
|
||||
resolveOwner,
|
||||
} from './internal/domain/owner-resolution.ts';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "name": "@modules/process", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.ts" } }
|
||||
Reference in New Issue
Block a user