三面拆分⑮:对齐 Mastra 数据流 —— 边只表达顺序,数据按输入/输出字段名流动

用户定的机制对齐: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
This commit is contained in:
2026-07-29 14:25:27 +08:00
co-authored by Claude Opus 5
parent 8d50ceda6a
commit 03eb666ac2
22 changed files with 264 additions and 689 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
# module: process
职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图不检查 · 不闭合不得激活)、
职责: 流程层:流程包定义与版本、**闭合校验**(边只表达顺序与条件 · 画图不检查 · 不闭合不得激活)、
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
画布的绘制(见 apps/web 的 gov-process)
@@ -25,6 +25,12 @@
被依赖: trigger, app(治理台 · 工作流管理)
不变量(OS 03 / 3.2):
- **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射,
只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域
(起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取;
约束力全在两端活动的输入/输出 schema 上 —— 名字即管道,闭合校验只拦
「同名不同型」这一条硬伤;必填有没有着落是运行时受理补全单的事,不拦激活。
改名映射不存在:上游发的名就得是下游收的名,名不对改**契约**,不是加映射。
- **宽进**(2026-07-30):`start` 不再按调用接口拒缺项 —— 输入可以不定或为空,
收敛点在活动(任务侧受理归一 + 补全单)。`CallInterfaceViolationError` 保留导出,
仅存量引用,起单路径不再抛它
@@ -1,4 +1,4 @@
/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
/** 闭合校验(名流动版)、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
import { describe, expect, it } from 'vitest';
import type { ProcessPackage } from '@contracts';
import { checkClosure } from '../internal/domain/closure.ts';
@@ -39,17 +39,9 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
{ 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 },
// 边只表达顺序与条件(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,
@@ -58,40 +50,32 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
const contracts = new Map([['a4', face()]]);
describe('checkClosure —— 不闭合不得激活', () => {
it('必填全映射、岗位存在即可激活', () => {
it('结构闭合、活动在库即可激活 —— 必填有没有着落是运行时补全单的事,不拦激活', () => {
const report = checkClosure({ pkg: pkg(), contracts });
expect(report.violations).toEqual([]);
expect(report.closed).toBe(true);
});
it('边未完成映射即拦', () => {
const broken = pkg({
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: '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 },
{ 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 report = checkClosure({ pkg: broken, contracts });
expect(report.closed).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 }).violations;
expect(violations.filter((v) => v.kind === 'required-unmapped').map((v) => v.field)).toEqual(['basePrice']);
const violations = checkClosure({ pkg: chain, contracts: clash }).violations;
expect(violations.filter((v) => v.kind === 'type-conflict').map((v) => v.field)).toEqual(['quote']);
});
it('引用未安装的活动即拦', () => {
@@ -124,7 +108,7 @@ describe('checkClosure —— 不闭合不得激活', () => {
edges: [
...pkg().edges,
// A4 已有一条无条件出边 A4→end;再加一条无条件出边即违规
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, mapping: [], reopen: false },
{ id: 'e9', from: 'A4' as never, to: 'end2' as never, reopen: false },
],
});
const violations = checkClosure({ pkg: twoDefaults, contracts }).violations;
@@ -161,18 +145,14 @@ describe('checkClosure —— 不闭合不得激活', () => {
{ 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: 'e0', from: 'start' as never, to: 'A3' as never, 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 },
{ id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false },
],
});
const violations = checkClosure({ pkg: tolerant, contracts }).violations;
+1 -1
View File
@@ -40,7 +40,7 @@ export { createProcessService } from './internal/usecase/process-service.ts';
export {
advanceOrder,
assemble,
flatScope,
dispatchOf,
rejectRouteOf,
selectEdges,
@@ -29,7 +29,7 @@ import type {
} from '../../port.ts';
import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts';
import type { NodeInstance } from '../domain/owner-resolution.ts';
import { assemble, rejectRouteOf } from '../domain/routing.ts';
import { flatScope, rejectRouteOf } from '../domain/routing.ts';
import { compilePackage } from './workflow-compiler.ts';
import type { CompiledFlow, RunResult } from './workflow-compiler.ts';
@@ -170,7 +170,7 @@ export function createMastraRuntime(deps: MastraRuntimeDeps): FlowRuntime {
assembleEdge: async ({ workflowId, edge, payload }) => {
const live = await requireWorkflow(workflowId);
const scope = scopeOf(live);
return assemble(edge, { ...scope, ports: { ...scope.ports, [edge.from]: payload } }, deps.sfeel.expression);
return flatScope({ ...scope, ports: { ...scope.ports, [edge.from]: payload } }, payload);
},
evaluate: async ({ workflowId, condition, payload }) => {
@@ -435,7 +435,7 @@ export function createMastraRuntime(deps: MastraRuntimeDeps): FlowRuntime {
if (verdict === 'reject') {
const hasRejectEdge = pkg.edges.some((e) => e.from === nodeId && (e.condition ?? '').includes('reject'));
if (!hasRejectEdge) {
const route = rejectRouteOf(node, scopeOf(record), deps.sfeel.expression);
const route = rejectRouteOf(node, scopeOf(record));
if (route.kind === 'terminate') {
await deps.store.updateWorkflow(workflowId, {
state: 'terminated',
@@ -55,7 +55,7 @@ export function checkClosure(input: ClosureInput): ClosureReport {
}
if (node.kind === 'activity') {
violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts));
violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts, byId));
}
// 岗位不入编排(活动点与审批点同一口径,2026-07-28 第五轮):没有岗位可核
/*
@@ -86,6 +86,7 @@ function checkActivityNode(
node: Extract<ProcessNode, { kind: 'activity' }>,
inbound: readonly Edge[],
contracts: ReadonlyMap<string, ActivityContractFace>,
byId: ReadonlyMap<string, ProcessNode>,
): ClosureViolation[] {
const out: ClosureViolation[] = [];
const face = contracts.get(node.activityId);
@@ -99,39 +100,28 @@ function checkActivityNode(
return out;
}
// 连一条边即须完成该边映射
/*
* 数据按字段名流动(2026-07-30 对齐 Mastra):边不带映射,「必填有没有着落」
* 不再是激活阻断 —— 宽进纪律下,缺的字段运行时由受理补全单找人收齐。
* 激活闸只留 schema 咬合这一条硬伤:**上游产出与下游输入同名却不同型**,
* 名字既是管道,名同型异一定坏 —— 这必须在设计期拦下。
*/
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}`,
});
const upstream = byId.get(edge.from);
if (upstream?.kind !== 'activity') continue;
const upFace = contracts.get(upstream.activityId);
if (upFace === undefined) continue;
for (const [field, targetType] of Object.entries(face.inputTypes)) {
for (const portFields of Object.values(upFace.outputs)) {
const sourceType = portFields[field];
if (sourceType !== undefined && sourceType !== targetType) {
out.push({
kind: 'type-conflict',
edgeId: edge.id,
field,
message: `边 ${edge.id} 同名字段 ${field} 类型相抵:上游 ${sourceType} → 下游 ${targetType}(名字即管道,名同型异必坏)`,
});
}
}
}
}
@@ -166,14 +156,20 @@ function checkTolerateDependencies(
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)) {
const source = byId.get(edge.from);
if (source?.kind !== 'activity') continue;
const srcFace = contracts.get(source.activityId);
if (srcFace === undefined) continue;
// 按名判依赖:容忍分支产出的字段名恰是下游的必填 —— 分支可空,依赖它的必填就会空
const produced = new Set(Object.values(srcFace.outputs).flatMap((fields) => Object.keys(fields)));
for (const required of face.requiredInputs) {
if (produced.has(required)) {
out.push({
kind: 'tolerate-required-dependency',
edgeId: edge.id,
nodeId: target.id,
field: mapping.target,
message: `容忍分支 ${edge.from} 的输出被下游必填字段 ${mapping.target} 依赖 —— 汇合处必填映射不得依赖可空分支`,
field: required,
message: `容忍分支 ${edge.from} 产出的 ${required} 被下游必填依赖 —— 分支可空,依赖它的必填就会空`,
});
}
}
@@ -17,50 +17,28 @@ export type Dispatch =
/** 流程包显式终点:按输出声明交付、按来源回执 */
| { readonly kind: 'finish'; readonly node: Extract<ProcessNode, { kind: 'end' }>; readonly payload: Record<string, unknown> };
/** 边映射装配:上游输出口载荷 / 流程变量 / 起单载荷 / 常量 → 下游 data 字段 */
/** 工作流作用域 —— 数据按**字段名**流动(对齐 Mastra):边不带映射,约束力在两端契约 schema 上 */
export type AssembleScope = {
readonly ports: Readonly<Record<string, Record<string, unknown>>>;
readonly vars: Readonly<Record<string, unknown>>;
readonly initiation: Readonly<Record<string, unknown>>;
};
export function assemble(
edge: Edge,
scope: AssembleScope,
evaluateExpression: (sfeel: string, scope: Readonly<Record<string, unknown>>) => unknown,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
const flat: Record<string, unknown> = { ...scope.initiation, ...scope.vars, ...scope.ports };
for (const mapping of edge.mapping) {
switch (mapping.source.from) {
case 'output-port':
out[mapping.target] = pick(scope.ports[mapping.source.port], mapping.source.path);
break;
case 'workflow-var':
out[mapping.target] = scope.vars[mapping.source.name];
break;
case 'initiation':
out[mapping.target] = pick(scope.initiation, mapping.source.path);
break;
case 'constant':
out[mapping.target] = mapping.source.value;
break;
case 'expression':
out[mapping.target] = evaluateExpression(mapping.source.sfeel, flat);
break;
}
/**
* 铺平作用域:起单入参垫底,沿途各输出口的字段依次盖上(后发生的赢),
* 最后是**刚发射的载荷**(Mastra 语义:下一步的 input 首先是上一步的 output)。
* `__` 前缀是运行时自留地(胶合层的 __runSeq / 终点的 __output),不入业务作用域。
*/
export function flatScope(scope: AssembleScope, latest: Record<string, unknown> = {}): Record<string, unknown> {
const out: Record<string, unknown> = { ...scope.initiation };
for (const [port, payload] of Object.entries(scope.ports)) {
if (port.startsWith('__')) continue;
if (payload !== null && typeof payload === 'object') Object.assign(out, payload);
}
Object.assign(out, latest);
for (const key of Object.keys(out)) if (key.startsWith('__')) delete out[key];
return out;
}
function pick(source: Readonly<Record<string, unknown>> | undefined, path: string): unknown {
if (source === undefined) return undefined;
return path.split('.').reduce<unknown>((acc, seg) => {
if (acc === null || acc === undefined || typeof acc !== 'object') return undefined;
return (acc as Record<string, unknown>)[seg];
}, source);
}
/**
* 从某节点出发,算出接下来该走的边。
* XOR:条件求值,**恰取一条**;AND:全部出边同时走(分叉)。
@@ -102,18 +80,10 @@ export type RejectRoute =
export function rejectRouteOf(
node: Extract<ProcessNode, { kind: 'approval' }>,
scope: AssembleScope,
evaluateExpression: (sfeel: string, scope: Readonly<Record<string, unknown>>) => unknown,
): RejectRoute {
if (node.reject.kind === 'terminate') return { kind: 'terminate' };
return {
kind: 'reopen',
nodeId: node.reject.nodeId,
payload: assemble(
{ id: `${node.id}-reject`, from: node.id, to: node.reject.nodeId, mapping: node.reject.mapping, reopen: true },
scope,
evaluateExpression,
),
};
// 重开载荷 = 铺平作用域 —— 重入的节点按自己的输入契约取字段,不靠这里挑
return { kind: 'reopen', nodeId: node.reject.nodeId, payload: flatScope(scope) };
}
/** 起点:找出流程包的入口节点 */
@@ -25,7 +25,7 @@ import type {
} from '../../port.ts';
import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts';
import type { NodeInstance } from '../domain/owner-resolution.ts';
import { assemble, dispatchOf, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts';
import { dispatchOf, flatScope, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts';
export type SelfHostedRuntimeDeps = {
readonly store: ProcessStore;
@@ -231,7 +231,8 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
* 节点只收**装配后的**载荷:活动与审批点都有契约,不被上游多余字段污染;
* 判据要用的隐性上下文(审批 verdict、放行选口 outcome)由系统注入判据作用域,不走映射。
*/
const arriving = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression);
// 数据按字段名流动(对齐 Mastra):边不带映射 —— 下游收铺平作用域,按自己的契约取字段
const arriving = flatScope({ ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, payload);
try {
await enter(record, pkg, target, arriving);
} catch (error) {
@@ -302,8 +303,8 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
const edge = pkg.edges.find((e) => e.id === disposition.edgeId);
const target = edge === undefined ? undefined : pkg.nodes.find((n) => n.id === edge.to);
if (edge !== undefined && target !== undefined) {
// 失败载荷 = 终态、原因码与说明
const payload = assemble(edge, scopeOf(record), deps.sfeel.expression);
// 失败改道:载荷照样是铺平作用域 + 终态标记 —— 接失败的节点也按自己的契约取字段
const payload = flatScope(scopeOf(record));
await enter(record, pkg, target, { ...payload, __terminal: terminal });
}
}
@@ -401,7 +402,7 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
*/
const routedByEdge = await advanceFrom(record, pkg, nodeId, carried, true);
if (!routedByEdge) {
const route = rejectRouteOf(node, scopeOf(record), deps.sfeel.expression);
const route = rejectRouteOf(node, scopeOf(record));
if (route.kind === 'terminate') {
await deps.store.updateWorkflow(workflowId, { state: 'terminated', finishedAt: deps.clock.now().toISOString() });
} else {