三面拆分⑮:对齐 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
@@ -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) };
}
/** 起点:找出流程包的入口节点 */