三面拆分⑬:流程包纯编排化 —— 编排与传递契约归包,实现方式归库,激活看双判据

模型(用户定):流程包只决定活动编排 + 活动传递的契约,不决定活动的实现方式;
节点只分活动点与审批点;图只定名称与顺序,属性在卡上填,链接好实现即可运行;
激活 = ①Mastra 式图编排闭合完善 + ②活动装载,两面判据。

schema(库经外科手术清空,无历史包袱,网关彻底出局):
- contracts:删 gateway 节点变体 / GATEWAY_KINDS / GATEWAY_KIND_LABEL /
  'gateway-deprecated' 违规项 —— 节点四种:start/end(结构件)+ activity/approval
- routing:joinReady 整个删除(多入边即异或汇合,没有「等回齐」;AND 待并行组);
  selectEdges 精简;closure 删 checkGateway;process-service 删汇合等待与网关直传,
  节点一律只收装配后载荷(verdict/outcome 判据由系统注入,不走映射)

编排面(画布 = 名称 + 顺序,其余在属性卡):
- 元素盘五件:活动点(单一,不再分标准/案例 —— 那是实现方式)/审批点/起止/卡点
- 属性卡:节点类型二分(活动点/审批点);新增「链接活动实现」选择器(装载面,
  FlowNode.act 经 merge 成为语义 activityId);范式/版本降为只读装载信息
- 边属性可填:条件(S-FEEL)+ 映射(上游输出口/起单载荷/常量三种来源;
  workflow-var/expression 归系统隐性,不进设计者面)—— FlowEdge.condition/mapping
  工作副本,pkg-to-graph 回填、graph-to-pkg 合并,清空条件即变默认兜底边
- 异常看板 → 激活双判据看板:图编排(结构+属性+契约)与活动装载(unknown-activity)
  分面亮灯,两面清零激活才亮
- pkg-to-graph 去 paradigms 绿化与网关分支;图例/文案全线「活动点/审批点」

测试:closure 27(joinReady 两组 describe 随机制摘除)、advance-order 按真图形状重写
(去 as never 捏造的网关节点)、graph-to-pkg 9(新增 act 合并 + 边契约合并/清空用例)、
seed 8、端到端八例真库全绿;五闸退出码验过(api:check 提交后转绿)。

库:purge 后重种,唯一 unified-quote@v1 生效(10 节点 14 边,双面判据齐)。
债:M5-5 清账(兼容路径连 schema 一起摘);M5-4(并行组)仍挂。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 18:29:32 +08:00
co-authored by Claude Fable 5
parent 6854524784
commit 281f4ef084
22 changed files with 458 additions and 634 deletions
+2 -3
View File
@@ -102,10 +102,9 @@ export function FlowFigureBlock({
</span>
<span><i className="lg-i fe-pi-start" aria-hidden /> 起始</span>
<span><i className="lg-i fe-pi-end" aria-hidden /> 结束</span>
<span><i className="lg-i fe-pi-sop" aria-hidden /> 标准活动</span>
<span><i className="lg-i fe-pi-case" aria-hidden /> 案例活动</span>
{/* 节点只分活动点/审批点 —— 标准/案例是实现方式,不进编排面的词汇表 */}
<span><i className="lg-i fe-pi-sop" aria-hidden /> 活动点</span>
<span><i className="lg-i fe-pi-approval" aria-hidden /> 审批点</span>
{/* 网关已废除(2026-07-28):分支 = 条件出边,图例不再教它 */}
<span><i className="lg-i fe-pi-callout" aria-hidden /> 卡点</span>
<span><i className="lg-i lg-flow" aria-hidden /> 顺序流</span>
<span className="sep">|</span>
@@ -1,9 +1,10 @@
/**
* 画布图 → 流程包:**合并**,不是重建。
*
* 这里守两条容易丢东西的路:
* ① 画布装不下的声明(边映射、驳回走向、负责人策略)保存后必须还在;
* ② 回边以**卡点**呈现(MVP 的画法)—— 存盘要还原成语义边,不能连边一起丢。
* 这里守三条容易丢东西的路:
* ① 画布装不下的声明(驳回走向、负责人策略)保存后必须还在;
* ② 回边以**卡点**呈现(MVP 的画法)—— 存盘要还原成语义边,不能连边一起丢;
* ③ 属性卡的工作副本(节点的链接实现 act、边的条件/映射)保存时要合并为语义。
*/
import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts';
@@ -27,14 +28,22 @@ const basePkg: ProcessPackage = processPackageSchema.parse({
owner: { position: 'BD', strategy: 'rotation' },
failure: { kind: 'terminate' },
},
{ id: 'G', kind: 'gateway', label: '岔', gateway: 'exclusive', mode: 'fork' },
{
id: 'B',
kind: 'activity',
label: '乙',
activityId: 'act-b',
owner: { position: 'PM', strategy: 'sole' },
failure: { kind: 'terminate' },
},
{ id: 'E', kind: 'end', label: '终' },
],
edges: [
{ id: 'e-s-a', from: 'S', to: 'A', mapping: [{ target: 'x', source: { from: 'initiation', path: 'x' } }] },
{ id: 'e-a-g', from: 'A', to: 'G', mapping: [] },
{ id: 'e-g-e', from: 'G', to: 'E', condition: 'ok = true', mapping: [] },
{ id: 'e-g-a', from: 'G', to: 'A', condition: 'ok = false', mapping: [], reopen: true },
{ id: 'e-a-b', from: 'A', to: 'B', mapping: [] },
// 乙的两条互斥出边:通过 → 终点;有异议 → 回甲重开(无网关:分支就在节点身上)
{ id: 'e-b-e', from: 'B', to: 'E', condition: 'ok = true', mapping: [] },
{ id: 'e-b-a', from: 'B', to: 'A', condition: 'ok = false', mapping: [], reopen: true },
],
variables: [],
});
@@ -47,32 +56,32 @@ const graph: FlowGraph = {
nodes: [
{ id: 'S', kind: 'start', label: '起' },
{ id: 'A', kind: 'activity', label: '甲', owner: 'BD' },
{ id: 'G', kind: 'gateway', label: '岔', mode: 'x' },
{ id: 'B', kind: 'activity', label: '乙', owner: 'PM' },
{ id: 'E', kind: 'end', label: '终' },
{ id: 'c1', kind: 'callout', label: '返回调整', to: 'A' },
],
edges: [
{ from: 'S', to: 'A' },
{ from: 'A', to: 'G' },
{ from: 'G', to: 'E', label: '通过', tone: 'ok' },
{ from: 'G', to: 'c1', label: '有异议', tone: 'bad' },
{ from: 'A', to: 'B' },
{ from: 'B', to: 'E', label: '通过', tone: 'ok' },
{ from: 'B', to: 'c1', label: '有异议', tone: 'bad' },
],
};
describe('mergeGraphIntoPackage', () => {
it('卡点还原成语义边 —— 回边不因「画成卡点」而消失', () => {
const merged = mergeGraphIntoPackage(basePkg, graph);
const back = merged.edges.find((e) => e.from === 'G' && e.to === 'A');
const back = merged.edges.find((e) => e.from === 'B' && e.to === 'A');
expect(back).toBeDefined();
// 旧边的 id 与 reopen 标记跟着保留:合并按 from→to 认亲
expect(back?.id).toBe('e-g-a');
expect(back?.id).toBe('e-b-a');
expect(back?.reopen).toBe(true);
expect(back?.condition).toBe('ok = false');
});
it('卡点本身不入语义节点,但整张图存进 diagram', () => {
const merged = mergeGraphIntoPackage(basePkg, graph);
expect(merged.nodes.map((n) => n.id)).toEqual(['S', 'A', 'G', 'E']);
expect(merged.nodes.map((n) => n.id)).toEqual(['S', 'A', 'B', 'E']);
const diagram = merged.diagram as { nodes: { id: string }[] };
expect(diagram.nodes.map((n) => n.id)).toContain('c1');
});
@@ -87,7 +96,7 @@ describe('mergeGraphIntoPackage', () => {
const merged = mergeGraphIntoPackage(basePkg, withReject);
expect(merged.edges.some((e) => e.from === 'AP' && e.to === 'A')).toBe(false);
// 旧包里有的回边照旧认得出来
expect(merged.edges.some((e) => e.from === 'G' && e.to === 'A')).toBe(true);
expect(merged.edges.some((e) => e.from === 'B' && e.to === 'A')).toBe(true);
});
it('画布装不下的声明原样保留:边映射与负责人策略', () => {
@@ -96,4 +105,40 @@ describe('mergeGraphIntoPackage', () => {
const a = merged.nodes.find((n) => n.id === 'A');
expect(a?.kind === 'activity' && a.owner.strategy).toBe('rotation');
});
/* ── 属性卡工作副本的合并(2026-07-28 第二轮:节点链接实现、边传递契约) ── */
it('节点的 act(链接的活动实现)优先于旧声明成为 activityId', () => {
const linked: FlowGraph = {
...graph,
nodes: graph.nodes.map((n) => (n.id === 'A' ? { ...n, act: 'act-new' } : n)),
};
const merged = mergeGraphIntoPackage(basePkg, linked);
const a = merged.nodes.find((n) => n.id === 'A');
expect(a?.kind === 'activity' && a.activityId).toBe('act-new');
// 没动过的节点沿用旧声明
const b = merged.nodes.find((n) => n.id === 'B');
expect(b?.kind === 'activity' && b.activityId).toBe('act-b');
});
it('边的条件/映射工作副本优先;清空条件 = 变默认兜底边', () => {
const edited: FlowGraph = {
...graph,
edges: graph.edges.map((e) =>
e.from === 'B' && e.to === 'E'
? { ...e, condition: 'ok = true and sum > 3', mapping: [{ target: 'y', source: { from: 'initiation', path: 'y' } }] }
: e.from === 'B' && e.to === 'c1'
? { ...e, condition: '' } // 设计者把条件清空:这条边成了默认兜底
: e,
),
};
const merged = mergeGraphIntoPackage(basePkg, edited);
const pass = merged.edges.find((e) => e.id === 'e-b-e');
expect(pass?.condition).toBe('ok = true and sum > 3');
expect(pass?.mapping).toHaveLength(1);
const back = merged.edges.find((e) => e.id === 'e-b-a');
expect(back?.condition).toBeUndefined();
// 没动过的边沿用旧声明
expect(merged.edges.find((e) => e.id === 'e-s-a')?.mapping).toHaveLength(1);
});
});
@@ -55,7 +55,7 @@ type Any = any;
export interface NewNodeSpec {
kind: FlowNodeKind;
flavor?: "case" | "approval";
flavor?: "approval";
mode?: "x" | "and";
}
@@ -430,12 +430,12 @@ const modelingModule = {
/* ---------- 元素盘(palette):拖放/点击落子 ---------- */
/*
* 网关不再入盘(产品决策 2026-07-28):分支 = 源节点的多条条件出边,汇合 = 多入边异或。
* 画布仍**认得** gateway(渲染/序列化都留着)—— 历史版本要能看;只是不给新画。
* 节点只分**活动点**与**审批点**(2026-07-28 第二轮):
* 编排面不选实现方式(标准/案例是活动库的事,链接哪个实现在属性卡决定);
* 网关不存在(分支 = 条件出边);起止与卡点是结构件与呈现件。
*/
export const PALETTE_SPECS: Array<{ key: string; label: string; className: string; spec: NewNodeSpec }> = [
{ key: "create.sop", label: "标准活动 · flow(蓝 · 照谱执行)", className: "fe-pi fe-pi-sop", spec: { kind: "activity" } },
{ key: "create.case", label: "案例活动 · loop(绿 · 临场演绎)", className: "fe-pi fe-pi-case", spec: { kind: "activity", flavor: "case" } },
{ key: "create.act", label: "活动点(蓝 · 链接活动实现后可运行)", className: "fe-pi fe-pi-sop", spec: { kind: "activity" } },
{ key: "create.approval", label: "审批点(琥珀 · 人裁节点)", className: "fe-pi fe-pi-approval", spec: { kind: "activity", flavor: "approval" } },
{ key: "create.start", label: "起始事件", className: "fe-pi fe-pi-start", spec: { kind: "start" } },
{ key: "create.end", label: "结束事件", className: "fe-pi fe-pi-end", spec: { kind: "end" } },
@@ -606,10 +606,8 @@ class FlowContextPad {
},
};
};
appendEntry("append.sop", "后接标准活动(自动连线)", "fe-ci fe-pi-sop", { kind: "activity" });
appendEntry("append.case", "后接案例活动(自动连线)", "fe-ci fe-pi-case", { kind: "activity", flavor: "case" });
appendEntry("append.act", "后接活动点(自动连线)", "fe-ci fe-pi-sop", { kind: "activity" });
appendEntry("append.approval", "后接审批点(自动连线)", "fe-ci fe-pi-approval", { kind: "activity", flavor: "approval" });
// 网关已废除,不再可追加 —— 分支画成本节点的多条条件出边即可
appendEntry("append.end", "后接结束事件(自动连线)", "fe-ci fe-pi-end", { kind: "end" });
entries["connect"] = {
@@ -53,6 +53,8 @@ export interface FlowNode {
assign?: "auto" | "assist" | "human";
/** Wait/等待(BPMN 调用活动):被调流程规范名(软引用);画双边框+等待角词,点击跳被调流程。 */
wait?: string;
/** 仅活动点:**链接的活动实现 id**(装载面)。属性卡选定,保存时合并为语义 activityId。 */
act?: string | undefined;
/** 手绘图(graph.manual)专用:节点左上角坐标(px,MANUAL_GRID 的倍数)。 */
x?: number;
y?: number;
@@ -72,6 +74,10 @@ export interface FlowEdge {
to: string;
label?: string;
tone?: FlowTone;
/** 边属性(传递契约)的工作副本:S-FEEL 条件。属性卡填写,保存时合并为语义边的 condition。 */
condition?: string | undefined;
/** 边属性(传递契约)的工作副本:字段映射(形状同语义边 mapping)。保存时合并。 */
mapping?: unknown[] | undefined;
/** 手绘图专用:整条折线路径(px,含两端;每段水平或垂直)。 */
points?: Array<{ x: number; y: number }>;
/** 手绘图专用:线标签文字中心点(注记,自由摆放)。 */
+7 -20
View File
@@ -32,12 +32,9 @@ export function mergeGraphIntoPackage(
priorEdge.set(`${from}→${to}`, e);
}
// 画布节点里只有五要素入语义;卡点(callout)留在 diagram
// 画布节点里只有活动点/审批点/起终点入语义;卡点(callout)留在 diagram
const semantic = graph.nodes.filter((n) => n.kind !== 'callout');
const degree = (id: string, dir: 'in' | 'out') =>
graph.edges.filter((e) => (dir === 'in' ? e.to === id : e.from === id)).length;
const nodes: ProcessNode[] = semantic.map((n) => {
const prior = renamed.get(n.id);
const id = nodeIdSchema.parse(n.id);
@@ -63,18 +60,6 @@ export function mergeGraphIntoPackage(
};
}
if (n.kind === 'gateway') {
const priorGw = prior?.kind === 'gateway' ? prior : undefined;
return {
...base,
kind: 'gateway' as const,
gateway: (n.mode === 'and' ? 'parallel' : 'exclusive') as 'parallel' | 'exclusive',
// 分叉/汇合看度数:出边多为叉,入边多为汇;旧声明优先(度数打平时不猜)
mode: priorGw?.mode ?? (degree(n.id, 'out') > degree(n.id, 'in') ? ('fork' as const) : ('join' as const)),
...(n.basis === undefined ? {} : { basis: n.basis }),
};
}
if (n.flavor === 'approval') {
const priorAp = prior?.kind === 'approval' ? prior : undefined;
return {
@@ -95,7 +80,8 @@ export function mergeGraphIntoPackage(
return {
...base,
kind: 'activity' as const,
activityId: priorAct?.activityId ?? placeholderActivityId(n.label),
// 链接优先级:属性卡刚选的(act)> 旧声明 > 占位软引用(闭合校验标「未装载」)
activityId: n.act !== undefined && n.act !== '' ? activityIdSchema.parse(n.act) : (priorAct?.activityId ?? placeholderActivityId(n.label)),
owner: {
position: n.owner ?? priorAct?.owner.position ?? '未声明',
strategy: priorAct?.owner.strategy ?? ('sole' as const),
@@ -135,13 +121,14 @@ export function mergeGraphIntoPackage(
.filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to))
.map((e) => {
const prior = priorEdge.get(`${e.from}→${e.to}`);
// 边属性(传递契约)以属性卡的工作副本优先;没动过的沿用旧声明
const condition = e.condition !== undefined ? (e.condition.trim() === '' ? undefined : e.condition) : prior?.condition;
return {
id: prior?.id ?? `e-${e.from}-${e.to}`,
from: nodeIdSchema.parse(e.from),
to: nodeIdSchema.parse(e.to),
// 装配映射与条件是「画布上看不见的声明」—— 原样保留,画布删不掉它们
mapping: prior?.mapping ?? [],
...(prior?.condition === undefined ? {} : { condition: prior.condition }),
mapping: (e.mapping as Edge['mapping'] | undefined) ?? prior?.mapping ?? [],
...(condition === undefined ? {} : { condition }),
reopen: prior?.reopen ?? false,
...(e.label === undefined ? {} : { label: e.label }),
...(e.tone === 'ok' || e.tone === 'bad' ? { tone: e.tone } : {}),
+29 -25
View File
@@ -20,12 +20,7 @@ export type InstanceMark = {
/** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */
const ROW_WIDTH = 4;
export function packageToGraph(
pkg: ProcessPackage,
marks: readonly InstanceMark[] = [],
/** 活动范式(activityId → loop|flow):案例活动画绿 —— 范式住活动库,包里只有引用 */
paradigms?: ReadonlyMap<string, string>,
): FlowGraph {
export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark[] = []): FlowGraph {
const markOf = new Map(marks.map((m) => [m.nodeId, m]));
/*
@@ -46,38 +41,47 @@ export function packageToGraph(
const bottom = stored.nodes.reduce((max, n) => Math.max(max, (n.y ?? 0)), 0) + 90;
const extras: FlowNode[] = missing.map((n, i) => ({
id: n.id,
kind: n.kind === 'gateway' ? ('gateway' as const) : n.kind === 'start' ? ('start' as const) : n.kind === 'end' ? ('end' as const) : ('activity' as const),
kind: n.kind === 'start' ? ('start' as const) : n.kind === 'end' ? ('end' as const) : ('activity' as const),
label: n.label,
...(n.kind === 'gateway' ? { mode: n.gateway === 'parallel' ? ('and' as const) : ('x' as const) } : {}),
...(n.kind === 'approval' ? { flavor: 'approval' as const, owner: n.approver.position } : {}),
...(n.kind === 'activity' ? { code: n.id, owner: n.owner.position } : {}),
...(n.kind === 'activity' ? { code: n.id, owner: n.owner.position, act: n.activityId } : {}),
x: 30 + (i % 6) * 160,
y: bottom + Math.floor(i / 6) * 60,
}));
const semanticById = new Map(pkg.nodes.map((n) => [n.id as string, n]));
const withState = (list: readonly FlowNode[]) =>
list.map((n) => {
const state = stateOf(markOf.get(n.id));
return state === undefined ? n : { ...n, note: state };
const sem = semanticById.get(n.id);
// 链接的实现(装载面)以语义为准回填 —— 存证里的 act 只是画布工作痕迹
const linked = sem?.kind === 'activity' ? { act: sem.activityId as string } : {};
const base = { ...n, ...linked };
return state === undefined ? base : { ...base, note: state };
});
return { ...stored, nodes: [...withState(stored.nodes), ...withState(extras)] };
/*
* 边属性(条件/映射)从语义边回填到画布边:属性卡编辑的是画布工作副本,
* 保存时 graph-to-pkg 又以它为准 —— 回填缺席的话,打开即保存会把契约冲掉。
* 经卡点的边按卡点 `to` 认亲(A5→c_reject 即 A5→A4)。
*/
const calloutTo = new Map(
stored.nodes.filter((n) => n.kind === 'callout' && n.to !== undefined).map((n) => [n.id, n.to!]),
);
const semEdge = new Map(pkg.edges.map((e) => [`${e.from}→${e.to}`, e]));
const edges = stored.edges.map((e) => {
const sem = semEdge.get(`${e.from}→${calloutTo.get(e.to) ?? e.to}`);
if (sem === undefined) return e;
return {
...e,
...(sem.condition === undefined ? {} : { condition: sem.condition }),
mapping: sem.mapping as unknown[],
};
});
return { ...stored, edges, nodes: [...withState(stored.nodes), ...withState(extras)] };
}
const nodes: FlowNode[] = pkg.nodes.map((n) => {
const state = stateOf(markOf.get(n.id));
if (n.kind === 'gateway') {
return {
id: n.id,
kind: 'gateway',
label: n.label,
// 并行网关画 +,异或网关画 ×(判断)
mode: n.gateway === 'parallel' ? 'and' : 'x',
...(n.sub === undefined ? (n.mode === 'join' ? { sub: '汇合' } : { sub: '分叉' }) : { sub: n.sub }),
...(n.basis === undefined ? {} : { basis: n.basis }),
...(state === undefined ? {} : { note: state }),
};
}
if (n.kind === 'start') return { id: n.id, kind: 'start', label: n.label };
if (n.kind === 'end') return { id: n.id, kind: 'end', label: n.label };
@@ -96,7 +100,7 @@ export function packageToGraph(
return {
...base,
owner: n.owner.position,
...(paradigms?.get(n.activityId) === 'loop' ? { flavor: 'case' as const } : {}),
act: n.activityId,
...(n.in === undefined ? {} : { in: [...n.in] }),
...(n.out === undefined ? {} : { out: [...n.out] }),
};