三面拆分⑮:对齐 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
@@ -25,7 +25,6 @@ import {
Chip,
Field,
Kpi,
ListEdit,
Note,
PageHead,
Prop,
@@ -65,24 +64,20 @@ function flowsOf(packages: readonly ProcessPackage[]) {
});
}
/* ── 边属性:传递契约(条件 + 映射)──────────────────────────────────── */
/** 映射行的工作形状 —— 语义边 mapping 的元素;设计者只见三种来源,其余是系统隐性上下文 */
type MapRow = { target: string; source: Record<string, unknown> & { from: string } };
const SOURCE_KINDS = [
{ k: 'output-port', label: '上游输出口' },
{ k: 'initiation', label: '起单载荷' },
{ k: 'constant', label: '常量' },
] as const;
/* ── 边属性:只剩顺序与条件(2026-07-30 对齐 Mastra)────────────────────
Mastra 的边不传数据 —— 约束力全在两端活动的输入/输出字段(schema 咬合)。
数据按字段名流动:上游发的名就是下游收的名;边属性卡因此只剩条件与标签,
「映射 · 载荷装配」整节退役 —— 下游拿什么去**节点属性**的数据接口看。 */
function EdgeProps({ edge, onPatch }: { edge: FlowEdge; onPatch: (f: (e: FlowEdge) => FlowEdge) => void }) {
const rows = (edge.mapping ?? []) as MapRow[];
const setRows = (next: MapRow[]) => onPatch((e) => ({ ...e, mapping: next }));
const patchRow = (i: number, f: (r: MapRow) => MapRow) => setRows(rows.map((r, j) => (j === i ? f(r) : r)));
return (
<div className="g-props g-eprops">
<Prop ico="git-branch" title={`${edge.code ?? '边'} · ${edge.from} → ${edge.to}`} extra="多出边异或:命中即走;条件留空 = 默认兜底">
<Field k="S-FEEL 条件" hint="如 needSourcing = true;可用 and / or 连接。留空即无条件(默认边,至多一条)">
<Prop
ico="git-branch"
title={`${edge.code ?? '边'} · ${edge.from} → ${edge.to}`}
extra="边只表达顺序与条件 —— 数据按字段名自动流动"
>
<Field k="S-FEEL 条件" hint="读上游节点的输出字段,如 needSourcing = true;留空即无条件(默认边,至多一条)">
<input
className="g-input mono"
value={edge.condition ?? ''}
@@ -104,101 +99,6 @@ function EdgeProps({ edge, onPatch }: { edge: FlowEdge; onPatch: (f: (e: FlowEdg
/>
</Field>
</Prop>
<Prop ico="git-merge" title="映射 · 载荷装配" extra="必填映射齐了这条边才闭合">
{/* 明细表列头:行与列头同一 6 列栅格;非「上游输出口」的来源在口/路径两列上合并 */}
<div className="g-maphead" aria-hidden="true">
<span>下游字段</span>
<span className="a">←</span>
<span>来源</span>
<span>口名</span>
<span>路径 · 值</span>
<span />
</div>
{rows.map((r, i) => {
const known = SOURCE_KINDS.some((k) => k.k === r.source.from);
return (
<div className="g-maprow" key={i}>
<input
className="g-input mono"
value={r.target}
placeholder="下游字段"
onChange={(e) => patchRow(i, (x) => ({ ...x, target: e.target.value }))}
/>
<span className="g-maparr">←</span>
{known ? (
<select
className="g-select"
value={r.source.from}
onChange={(e) =>
patchRow(i, (x) => ({
...x,
source:
e.target.value === 'output-port'
? { from: 'output-port', port: '', path: '' }
: e.target.value === 'initiation'
? { from: 'initiation', path: '' }
: { from: 'constant', value: '' },
}))
}
>
{SOURCE_KINDS.map((k) => (
<option key={k.k} value={k.k}>
{k.label}
</option>
))}
</select>
) : (
// workflow-var / expression 等是系统侧来源 —— 保留展示,不给编辑入口
<Chip tone="mute">{String(r.source.from)}(系统)</Chip>
)}
{r.source.from === 'output-port' && (
<>
<input
className="g-input mono"
value={String(r.source['port'] ?? '')}
placeholder="口名"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, port: e.target.value } }))}
/>
<input
className="g-input mono"
value={String(r.source['path'] ?? '')}
placeholder="字段路径"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
/>
</>
)}
{r.source.from === 'initiation' && (
<input
className="g-input mono wide"
value={String(r.source['path'] ?? '')}
placeholder="起单字段路径"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))}
/>
)}
{r.source.from === 'constant' && (
<input
className="g-input mono wide"
value={String(r.source['value'] ?? '')}
placeholder="常量值"
onChange={(e) => patchRow(i, (x) => ({ ...x, source: { ...x.source, value: e.target.value } }))}
/>
)}
<button className="g-btn" onClick={() => setRows(rows.filter((_, j) => j !== i))}>
<Ico name="x" s={12} />
</button>
</div>
);
})}
<Acts
items={[
{
label: '加一条映射',
ico: 'plus',
onClick: () => setRows([...rows, { target: '', source: { from: 'output-port', port: '', path: '' } }]),
},
]}
/>
</Prop>
</div>
);
}
@@ -506,6 +406,49 @@ function FlowLab({
(node.act === undefined && semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId),
);
/*
* 数据接口的来源推导(对齐 Mastra 名流动):这个节点的每个输入字段,运行时会从哪来 ——
* ① 起单入参里有同名字段 → 起单带进;② 某个上游(沿边回溯)的输出口发同名字段 → 上游发;
* ③ 都没有 → 受理归一时派补全单找人收。设计期看得见,不必等跑起来猜。
*/
const fieldSources = (() => {
if (node === null) return new Map<string, string>();
const initFields = new Set(
Object.keys(((pkg.callInterface.input as { properties?: Record<string, unknown> }).properties ?? {})),
);
// 祖先集:沿边回溯(含回边,访问集防环)
const ancestors = new Set<string>();
const queue = [node.id];
while (queue.length > 0) {
const cur = queue.pop()!;
for (const e of graph?.edges ?? []) {
if (e.to !== cur || ancestors.has(e.from)) continue;
ancestors.add(e.from);
queue.push(e.from);
}
}
const actOf = (nid: string): string | undefined => {
const g = nodes.find((x) => x.id === nid);
if (g?.act !== undefined) return g.act;
const sem = pkg.nodes.find((x) => x.id === nid);
return sem?.kind === 'activity' ? sem.activityId : undefined;
};
const out = new Map<string, string>();
for (const f of refAct?.contract.inputFields ?? []) {
if (initFields.has(f.name)) {
out.set(f.name, '起单入参');
continue;
}
const from = [...ancestors].find((nid) =>
activities
.find((a) => a.activityId === actOf(nid))
?.contract.outputPorts.some((port) => port.fields.includes(f.name)),
);
out.set(f.name, from !== undefined ? `上游 ${from}` : '补全单');
}
return out;
})();
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
const liveReport = useQuery({
queryKey: ['gov', 'process-report', pkg.id, pkg.version],
@@ -661,17 +604,16 @@ function FlowLab({
label: n.label,
activityId: n.activityId,
// 契约不带负责人岗位 —— 分派归运营,依赖文件只描述干什么
// 编排上手写的注记(给人读)
declaredIn: n.in ?? [],
declaredOut: n.out ?? [],
/*
* 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。
* 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。
*/
contract: actContract.get(n.activityId) ?? null,
upstreamAssembly: pkg.edges
.filter((e) => e.to === n.id)
.map((e) => ({ from: e.from, mapping: e.mapping })),
/*
* 数据按字段名流动(对齐 Mastra):依赖文件不再列边映射 ——
* 上游从哪来,看 contract.input 的字段名对上游 output 的字段名。
*/
upstream: pkg.edges.filter((e) => e.to === n.id).map((e) => e.from),
})),
approvals: pkg.nodes
.filter((n) => n.kind === 'approval')
@@ -1038,22 +980,50 @@ function FlowLab({
)}
</Prop>
{node.kind === 'activity' && (
<Prop ico="git-merge" title="输入" extra="上游边映射的落点">
<ListEdit
items={node.in ?? []}
placeholder="如:客户邮件"
onChange={(v) => patch((n) => ({ ...n, in: v.length > 0 ? [...v] : undefined }))}
/>
</Prop>
)}
{node.kind === 'activity' && (
<Prop ico="upload" title="输出口" extra="下游边映射的源">
<ListEdit
items={node.out ?? []}
placeholder="如:受理记录"
onChange={(v) => patch((n) => ({ ...n, out: v.length > 0 ? [...v] : undefined }))}
/>
{node.kind === 'activity' && node.flavor !== 'approval' && (
/*
* 数据接口(对齐 Mastra):边不传数据,约束力全在输入/输出字段 ——
* 这里不填写、只**看清**:每个输入字段运行时从哪来(起单 / 上游同名 / 补全单),
* 每个输出口发什么名。要改字段,改的是活动契约,不是这张卡。
*/
<Prop
ico="git-merge"
title="数据接口 · 按字段名流动"
extra={refAct === undefined ? '先链接活动实现' : '契约即管道:名字对上,数据就到'}
>
{refAct === undefined ? (
<Note>链接活动实现后,这里列出它吃什么、吐什么、每个字段从哪来。</Note>
) : (
<>
<div className="g-dif">
<em>输入 · {refAct.contract.inputFields.length} 字段</em>
{refAct.contract.inputFields.map((f) => {
const src = fieldSources.get(f.name) ?? '补全单';
return (
<div key={f.name} className="g-dif-r">
<span className="k">
{f.name}
{f.required && <i>必填</i>}
</span>
<span className="t">{f.type}</span>
<span className={'s' + (src === '补全单' ? ' ask' : '')}>
{src === '补全单' ? '← 补全单(受理归一找人收)' : `← ${src}`}
</span>
</div>
);
})}
</div>
<div className="g-dif">
<em>输出 · {refAct.contract.outputPorts.length} 口</em>
{refAct.contract.outputPorts.map((port) => (
<div key={port.name} className="g-dif-r">
<span className="k">{port.name}</span>
<span className="f">{port.fields.length > 0 ? port.fields.join(' · ') : '(整包事实)'}</span>
</div>
))}
</div>
</>
)}
</Prop>
)}
</div>