用户定的机制对齐:Mastra 的边不传数据,所有约束力靠输入/输出字段(schema 咬合)完成。
照此把「边映射」整个退役:
· 契约:edge 去 mapping,审批 reject 去 mapping —— 边只剩 顺序 + 条件(读上游
输出字段)+ 标签/回边。fieldMapping 只留给触发器的起单装配(系统边界)。
存量包无需迁移:zod 解析时把旧 jsonb 里的 mapping 静默剥掉,图照常有效。
· 运行时:assemble(边装配)→ flatScope(铺平作用域)——
起单入参垫底 ← 沿途各输出口字段依次盖上 ← 刚发射的载荷(Mastra 语义:
下一步的 input 首先是上一步的 output)。下游按自己的契约取字段;
「__」前缀是运行时自留地,不入业务作用域。审批驳回重开、失败改道同一条路。
· 闭合校验:边未映射/必填未映射/容忍依赖三条映射类检查退役
(宽进之下必填有没有着落是运行时补全单的事,不拦激活);
激活闸只留一条硬伤 —— **同名不同型**:名字即管道,名同型异必坏,
容忍依赖改按名判。改名映射不存在:名不对改契约,不是加映射。
· 种子:A2 的入参 material 更名 pn(它就是 PN;上游 A1 发的名就得是它收的名),
契约 0.3.0、槽位 0.8.0;流程种子边全部去映射。
· 前端:边属性卡砍掉「映射 · 载荷装配」整节,只剩 E号/方向/条件/标签;
节点属性卡新增「数据接口 · 按字段名流动」——每个输入字段标出运行时从哪来
(起单入参 / 上游 X / 补全单),输出口列出发什么名;沿边回溯推导,设计期看得见。
画布保存与依赖文件导出同步去映射。
E2E 顺带把宽进接进真流:快速归档流的 A3 要 outcome 而流里没人产 ——
测试如实走补全路(等人 → 答卡 outcome=won → 开跑 → completed)。
实测走查:A2 节点卡上 pn←上游A1、rfqRef←起单入参、scenario←补全单,推导全对。
五闸绿;契约快照随本提交入库。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
/** 闭合校验(名流动版)、负责人四策略、工作流状态聚合 —— 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: 'active',
|
|
callInterface: { input: {}, output: {} },
|
|
nodes: [
|
|
{ id: 'start' as never, kind: 'start', label: '起点' },
|
|
{
|
|
id: 'A4' as never,
|
|
kind: 'activity',
|
|
label: 'A4 成本定价',
|
|
activityId: 'a4' as never,
|
|
failure: { kind: 'terminate' },
|
|
},
|
|
{ id: 'end' as never, kind: 'end', label: '终点' },
|
|
],
|
|
edges: [
|
|
// 边只表达顺序与条件(2026-07-30 对齐 Mastra)—— 数据按字段名流动,不带映射
|
|
{ id: 'e1', from: 'start' as never, to: 'A4' as never, reopen: false },
|
|
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
|
|
],
|
|
variables: [],
|
|
...over,
|
|
}) as ProcessPackage;
|
|
|
|
const contracts = new Map([['a4', face()]]);
|
|
|
|
describe('checkClosure —— 不闭合不得激活', () => {
|
|
it('结构闭合、活动在库即可激活 —— 必填有没有着落是运行时补全单的事,不拦激活', () => {
|
|
const report = checkClosure({ pkg: pkg(), contracts });
|
|
expect(report.violations).toEqual([]);
|
|
expect(report.closed).toBe(true);
|
|
});
|
|
|
|
it('同名字段类型相抵即拦 —— 名字即管道,名同型异必坏', () => {
|
|
// 上游 done 口发 quote: number …下游若声明 quote: string 就是硬伤
|
|
const clash = new Map([
|
|
['a4', face({ inputTypes: { pn: 'string', quote: 'string' }, requiredInputs: ['pn'] })],
|
|
]);
|
|
const chain = pkg({
|
|
nodes: [
|
|
{ id: 'start' as never, kind: 'start', label: '起点' },
|
|
{ id: 'A3' as never, kind: 'activity', label: '上游', activityId: 'a4' as never, failure: { kind: 'terminate' } },
|
|
{ id: 'A4' as never, kind: 'activity', label: '下游', activityId: 'a4' as never, failure: { kind: 'terminate' } },
|
|
{ id: 'end' as never, kind: 'end', label: '终点' },
|
|
],
|
|
edges: [
|
|
{ id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false },
|
|
{ id: 'e1', from: 'A3' as never, to: 'A4' as never, reopen: false },
|
|
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
|
|
],
|
|
});
|
|
const violations = checkClosure({ pkg: chain, contracts: clash }).violations;
|
|
expect(violations.filter((v) => v.kind === 'type-conflict').map((v) => v.field)).toEqual(['quote']);
|
|
});
|
|
|
|
it('引用未安装的活动即拦', () => {
|
|
const report = checkClosure({ pkg: pkg(), contracts: new Map() });
|
|
expect(report.violations.map((v) => v.kind)).toContain('unknown-activity');
|
|
});
|
|
|
|
|
|
it('孤立节点即拦', () => {
|
|
const orphan = pkg({
|
|
nodes: [
|
|
...pkg().nodes,
|
|
{
|
|
id: 'A9' as never,
|
|
kind: 'activity',
|
|
label: '游离节点',
|
|
activityId: 'a4' as never,
|
|
failure: { kind: 'terminate' },
|
|
},
|
|
],
|
|
});
|
|
const violations = checkClosure({ pkg: orphan, contracts }).violations;
|
|
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
|
|
});
|
|
|
|
|
|
it('多出边异或分支:两条无条件出边 = 都想当默认 —— multi-default-edge', () => {
|
|
const twoDefaults = pkg({
|
|
nodes: [...pkg().nodes, { id: 'end2' as never, kind: 'end', label: '另一终点' }],
|
|
edges: [
|
|
...pkg().edges,
|
|
// A4 已有一条无条件出边 A4→end;再加一条无条件出边即违规
|
|
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, reopen: false },
|
|
],
|
|
});
|
|
const violations = checkClosure({ pkg: twoDefaults, contracts }).violations;
|
|
expect(violations.filter((v) => v.kind === 'multi-default-edge').map((v) => v.nodeId)).toEqual(['A4']);
|
|
|
|
// 其中一条补上条件即闭合 —— 条件命中优先,无条件边只作默认兜底
|
|
const oneDefault = pkg({
|
|
nodes: twoDefaults.nodes,
|
|
edges: twoDefaults.edges.map((e) => (e.id === 'e9' ? { ...e, condition: 'ok = true' } : e)),
|
|
});
|
|
expect(
|
|
checkClosure({ pkg: oneDefault, contracts }).violations.filter((v) => v.kind === 'multi-default-edge'),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('容忍分支的输出不得被下游必填依赖', () => {
|
|
const tolerant = pkg({
|
|
nodes: [
|
|
{ id: 'start' as never, kind: 'start', label: '起点' },
|
|
{
|
|
id: 'A3' as never,
|
|
kind: 'activity',
|
|
label: 'A3 渠道寻源',
|
|
activityId: 'a4' as never,
|
|
failure: { kind: 'tolerate' },
|
|
},
|
|
{
|
|
id: 'A4' as never,
|
|
kind: 'activity',
|
|
label: 'A4 成本定价',
|
|
activityId: 'a4' as never,
|
|
failure: { kind: 'terminate' },
|
|
},
|
|
{ id: 'end' as never, kind: 'end', label: '终点' },
|
|
],
|
|
edges: [
|
|
{ id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false },
|
|
{
|
|
id: 'e1',
|
|
from: 'A3' as never,
|
|
to: 'A4' as never,
|
|
reopen: false,
|
|
},
|
|
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
|
|
],
|
|
});
|
|
const violations = checkClosure({ pkg: tolerant, contracts }).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');
|
|
});
|
|
|
|
/*
|
|
* 「完成」凭到过终点,不凭没有在跑的了。
|
|
* 实测过一次假成功:A1 办结、出口少了必填去向旗、G1 异或出边条件全不成立,
|
|
* 流无声停在 A1 —— 只有一个节点实例,状态却报 completed,界面上是一单「已完成」的报价。
|
|
*/
|
|
it('传了终点节点:到过终点才算完成', () => {
|
|
expect(
|
|
aggregateWorkflowState([inst({ nodeId: 'A1', terminal: 'released' }), inst({ nodeId: 'end', terminal: 'released' })], false, ['end']),
|
|
).toBe('completed');
|
|
});
|
|
|
|
it('传了终点节点:收敛了但没到终点 → 异常(卡住了,不是完成)', () => {
|
|
expect(aggregateWorkflowState([inst({ nodeId: 'A1', terminal: 'released' })], false, ['end'])).toBe('abnormal');
|
|
});
|
|
|
|
it('终点自己没 released 也不算完成', () => {
|
|
expect(
|
|
aggregateWorkflowState([inst({ nodeId: 'end', terminal: 'blocked', failureDigested: true })], false, ['end']),
|
|
).toBe('abnormal');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
/*
|
|
* joinReady 已随网关一并摘除(2026-07-28 第二轮):多入边即异或汇合 —— 上游互斥,
|
|
* 只会来一条,**没有「等回齐」这回事**;AND 并行待「并行组」声明时再带自己的汇合语义。
|
|
* 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。
|
|
*/
|