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

模型(用户定):流程包只决定活动编排 + 活动传递的契约,不决定活动的实现方式;
节点只分活动点与审批点;图只定名称与顺序,属性在卡上填,链接好实现即可运行;
激活 = ①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
+1 -1
View File
@@ -6,7 +6,7 @@ export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from
export type { ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts'; export type { ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts';
export type { ProcessServiceDeps } from './internal/usecase/process-service.ts'; export type { ProcessServiceDeps } from './internal/usecase/process-service.ts';
export { createProcessService } from './internal/usecase/process-service.ts'; export { createProcessService } from './internal/usecase/process-service.ts';
export { advanceOrder, assemble, dispatchOf, joinReady, rejectRouteOf, selectEdges, startNodeOf, } from './internal/domain/routing.ts'; export { advanceOrder, assemble, dispatchOf, rejectRouteOf, selectEdges, startNodeOf, } from './internal/domain/routing.ts';
export { checkClosure, closed } from './internal/domain/closure.ts'; export { checkClosure, closed } from './internal/domain/closure.ts';
export { aggregateWorkflowState, FAILURE_DIGESTIONS, isDigested, nextInstanceNumber, resolveOwner, } from './internal/domain/owner-resolution.ts'; export { aggregateWorkflowState, FAILURE_DIGESTIONS, isDigested, nextInstanceNumber, resolveOwner, } from './internal/domain/owner-resolution.ts';
export { createProcessStore } from './internal/adapter/process-store.ts'; export { createProcessStore } from './internal/adapter/process-store.ts';
-122
View File
@@ -587,26 +587,6 @@ export declare const listProcessPackagesRoute: {
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>; }, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{ edges: z.ZodArray<z.ZodObject<{
id: z.ZodString; id: z.ZodString;
@@ -791,26 +771,6 @@ export declare const saveProcessRoute: {
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>; }, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{ edges: z.ZodArray<z.ZodObject<{
id: z.ZodString; id: z.ZodString;
@@ -983,26 +943,6 @@ export declare const saveProcessRoute: {
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>; }, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{ edges: z.ZodArray<z.ZodObject<{
id: z.ZodString; id: z.ZodString;
@@ -1052,7 +992,6 @@ export declare const saveProcessRoute: {
"type-conflict": "type-conflict"; "type-conflict": "type-conflict";
"dangling-node": "dangling-node"; "dangling-node": "dangling-node";
"multi-default-edge": "multi-default-edge"; "multi-default-edge": "multi-default-edge";
"gateway-deprecated": "gateway-deprecated";
"tolerate-required-dependency": "tolerate-required-dependency"; "tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity"; "unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position"; "missing-owner-position": "missing-owner-position";
@@ -1215,26 +1154,6 @@ export declare const setProcessStateRoute: {
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>; }, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{ edges: z.ZodArray<z.ZodObject<{
id: z.ZodString; id: z.ZodString;
@@ -1412,26 +1331,6 @@ export declare const validateProcessRoute: {
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>; }, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{ edges: z.ZodArray<z.ZodObject<{
id: z.ZodString; id: z.ZodString;
@@ -1482,7 +1381,6 @@ export declare const validateProcessRoute: {
"type-conflict": "type-conflict"; "type-conflict": "type-conflict";
"dangling-node": "dangling-node"; "dangling-node": "dangling-node";
"multi-default-edge": "multi-default-edge"; "multi-default-edge": "multi-default-edge";
"gateway-deprecated": "gateway-deprecated";
"tolerate-required-dependency": "tolerate-required-dependency"; "tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity"; "unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position"; "missing-owner-position": "missing-owner-position";
@@ -2462,26 +2360,6 @@ export declare const govWorkflowRoute: {
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>; }, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{ edges: z.ZodArray<z.ZodObject<{
id: z.ZodString; id: z.ZodString;
+1 -51
View File
@@ -128,13 +128,6 @@ export declare const nudgeLadderSchema: z.ZodObject<{
}, z.core.$strip>>>; }, z.core.$strip>>>;
}, z.core.$strip>; }, z.core.$strip>;
export type NudgeLadder = z.infer<typeof nudgeLadderSchema>; export type NudgeLadder = z.infer<typeof nudgeLadderSchema>;
export declare const GATEWAY_KINDS: readonly ["parallel", "exclusive"];
export declare const gatewayKindSchema: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
export type GatewayKind = z.infer<typeof gatewayKindSchema>;
export declare const GATEWAY_KIND_LABEL: Readonly<Record<GatewayKind, string>>;
export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
kind: z.ZodLiteral<"start">; kind: z.ZodLiteral<"start">;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">; id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
@@ -253,26 +246,6 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">; }, z.core.$strip>], "kind">;
export type ProcessNode = z.infer<typeof processNodeSchema>; export type ProcessNode = z.infer<typeof processNodeSchema>;
export declare const callInterfaceSchema: z.ZodObject<{ export declare const callInterfaceSchema: z.ZodObject<{
@@ -427,26 +400,6 @@ export declare const processPackageSchema: z.ZodObject<{
x: z.ZodOptional<z.ZodNumber>; x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>; y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>, z.ZodObject<{
kind: z.ZodLiteral<"gateway">;
gateway: z.ZodEnum<{
parallel: "parallel";
exclusive: "exclusive";
}>;
mode: z.ZodEnum<{
join: "join";
fork: "fork";
}>;
basis: z.ZodOptional<z.ZodString>;
id: z.core.$ZodBranded<z.ZodString, "NodeId", "out">;
label: z.ZodString;
sub: z.ZodOptional<z.ZodString>;
layout: z.ZodOptional<z.ZodObject<{
row: z.ZodNumber;
col: z.ZodNumber;
x: z.ZodOptional<z.ZodNumber>;
y: z.ZodOptional<z.ZodNumber>;
}, z.core.$strip>>;
}, z.core.$strip>], "kind">>; }, z.core.$strip>], "kind">>;
edges: z.ZodArray<z.ZodObject<{ edges: z.ZodArray<z.ZodObject<{
id: z.ZodString; id: z.ZodString;
@@ -488,14 +441,13 @@ export declare const processPackageSchema: z.ZodObject<{
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>; diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>; }, z.core.$strip>;
export type ProcessPackage = z.infer<typeof processPackageSchema>; export type ProcessPackage = z.infer<typeof processPackageSchema>;
export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "multi-default-edge", "gateway-deprecated", "tolerate-required-dependency", "unknown-activity", "missing-owner-position"]; export declare const CLOSURE_VIOLATION_KINDS: readonly ["edge-unmapped", "required-unmapped", "type-conflict", "dangling-node", "multi-default-edge", "tolerate-required-dependency", "unknown-activity", "missing-owner-position"];
export declare const closureViolationKindSchema: z.ZodEnum<{ export declare const closureViolationKindSchema: z.ZodEnum<{
"edge-unmapped": "edge-unmapped"; "edge-unmapped": "edge-unmapped";
"required-unmapped": "required-unmapped"; "required-unmapped": "required-unmapped";
"type-conflict": "type-conflict"; "type-conflict": "type-conflict";
"dangling-node": "dangling-node"; "dangling-node": "dangling-node";
"multi-default-edge": "multi-default-edge"; "multi-default-edge": "multi-default-edge";
"gateway-deprecated": "gateway-deprecated";
"tolerate-required-dependency": "tolerate-required-dependency"; "tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity"; "unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position"; "missing-owner-position": "missing-owner-position";
@@ -508,7 +460,6 @@ export declare const closureViolationSchema: z.ZodObject<{
"type-conflict": "type-conflict"; "type-conflict": "type-conflict";
"dangling-node": "dangling-node"; "dangling-node": "dangling-node";
"multi-default-edge": "multi-default-edge"; "multi-default-edge": "multi-default-edge";
"gateway-deprecated": "gateway-deprecated";
"tolerate-required-dependency": "tolerate-required-dependency"; "tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity"; "unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position"; "missing-owner-position": "missing-owner-position";
@@ -528,7 +479,6 @@ export declare const closureReportSchema: z.ZodObject<{
"type-conflict": "type-conflict"; "type-conflict": "type-conflict";
"dangling-node": "dangling-node"; "dangling-node": "dangling-node";
"multi-default-edge": "multi-default-edge"; "multi-default-edge": "multi-default-edge";
"gateway-deprecated": "gateway-deprecated";
"tolerate-required-dependency": "tolerate-required-dependency"; "tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity"; "unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position"; "missing-owner-position": "missing-owner-position";
@@ -6,10 +6,9 @@ import { describe, expect, it } from 'vitest';
import { MVP_PACKAGE } from '../seed-mvp-flow.ts'; import { MVP_PACKAGE } from '../seed-mvp-flow.ts';
describe('MVP_PACKAGE', () => { describe('MVP_PACKAGE', () => {
it('10 语义节点 · 14 边 · 零网关 · 一审批点双终点', () => { it('10 语义节点 · 14 边 · 一审批点双终点(网关在 schema 里已不存在)', () => {
expect(MVP_PACKAGE.nodes).toHaveLength(10); expect(MVP_PACKAGE.nodes).toHaveLength(10);
expect(MVP_PACKAGE.edges).toHaveLength(14); expect(MVP_PACKAGE.edges).toHaveLength(14);
expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'gateway')).toHaveLength(0);
expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'approval')).toHaveLength(1); expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'approval')).toHaveLength(1);
expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'end')).toHaveLength(2); expect(MVP_PACKAGE.nodes.filter((n) => n.kind === 'end')).toHaveLength(2);
}); });
+3 -3
View File
@@ -246,11 +246,11 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
nodes: [ nodes: [
{ id: 'start', kind: 'start', label: '客户RFQ询价', x: 30, y: 70, lx: 44, ly: 104 }, { id: 'start', kind: 'start', label: '客户RFQ询价', x: 30, y: 70, lx: 44, ly: 104 },
{ id: 'A1', kind: 'activity', label: '客户RFQ受理', code: 'A1', owner: 'BD', x: 110, y: 60 }, { id: 'A1', kind: 'activity', label: '客户RFQ受理', code: 'A1', owner: 'BD', x: 110, y: 60 },
{ id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', owner: 'FAE', flavor: 'case', x: 290, y: 60 }, { id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', owner: 'FAE', x: 290, y: 60 },
{ id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', owner: 'PRM', flavor: 'case', x: 470, y: 60 }, { id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', owner: 'PRM', x: 470, y: 60 },
{ id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', owner: 'BD', x: 110, y: 310 }, { id: 'A4', kind: 'activity', label: '成本定价', code: 'A4', owner: 'BD', x: 110, y: 310 },
{ id: 'A5', kind: 'activity', label: '定价审批', code: 'A5', owner: 'PM', flavor: 'approval', x: 290, y: 310 }, { id: 'A5', kind: 'activity', label: '定价审批', code: 'A5', owner: 'PM', flavor: 'approval', x: 290, y: 310 },
{ id: 'A6', kind: 'activity', label: '报价议价', code: 'A6', owner: 'BD', flavor: 'case', x: 470, y: 310 }, { id: 'A6', kind: 'activity', label: '报价议价', code: 'A6', owner: 'BD', x: 470, y: 310 },
{ id: 'A7', kind: 'activity', label: '情报归档', code: 'A7', owner: 'BD', x: 630, y: 410 }, { id: 'A7', kind: 'activity', label: '情报归档', code: 'A7', owner: 'BD', x: 630, y: 410 },
{ id: 'end_won', kind: 'end', label: '转订单履约', x: 630, y: 320, lx: 640, ly: 352 }, { id: 'end_won', kind: 'end', label: '转订单履约', x: 630, y: 320, lx: 640, ly: 352 },
{ id: 'end_arch', kind: 'end-bad', label: '归档闭环', x: 790, y: 420, lx: 800, ly: 452 }, { id: 'end_arch', kind: 'end-bad', label: '归档闭环', x: 790, y: 420, lx: 800, ly: 452 },
+17 -10
View File
@@ -1,6 +1,6 @@
# module: process # module: process
职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图即校验 · 不闭合不得保存)、 职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图不检查 · 不闭合不得激活)、
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开 负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、 不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
画布的绘制(见 apps/web 的 gov-process) 画布的绘制(见 apps/web 的 gov-process)
@@ -14,7 +14,7 @@
- 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停) - 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停)
- 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState` - 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState`
(`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011) (`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011)
`isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady` `isDigested` `nextInstanceNumber` `selectEdges` `assemble`
`rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder` `rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder`
(`advanceOrder` = 推进次序,按边拓扑排、排除回边、并行支按编号定序; (`advanceOrder` = 推进次序,按边拓扑排、排除回边、并行支按编号定序;
呈现用,不参与流转判断 —— 图纸坐标不配决定次序) 呈现用,不参与流转判断 —— 图纸坐标不配决定次序)
@@ -28,13 +28,20 @@
- **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验, - **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验,
随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时 随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时
跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。 跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。
违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、含网关、 违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、
容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。 活动未装载、岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。
- **无网关**(产品决策 2026-07-28):分支 = 源节点的多条条件出边(恰取一条, - **纯编排模型**(产品决策 2026-07-28 第二轮):流程包只决定**活动编排**与
无条件边作默认兜底、至多一条),汇合 = 多入边异或(上游互斥,只会来一条)。 **活动传递的契约**,不决定活动的实现方式(范式/版本住活动库,节点仅以
schema 仍收 `kind: 'gateway'` 只为读历史版本 —— 在途工作流照走(运行时兼容), `activityId` 链接)。节点只分**活动点**与**审批点**(起点/终点是结构件);
但含网关的定义激活即 `gateway-deprecated` 违规,**仅可读,不可激活**。 `kind: 'gateway'` 已从 schema 摘除(库经外科手术清空,无历史包袱)。
分支 = 源节点的多条条件出边(恰取一条,无条件边作默认兜底、至多一条),
汇合 = 多入边异或(上游互斥,只会来一条 —— `joinReady` 已删,没有「等回齐」)。
AND 并行暂无声明机制,将来以「并行组」加回(对应 Mastra `.parallel`,见 debt.md)。 AND 并行暂无声明机制,将来以「并行组」加回(对应 Mastra `.parallel`,见 debt.md)。
- **激活双判据**:①图编排闭合完善(结构 + 属性 + 传递契约,`checkClosure` 的
非 unknown-activity 项)②活动装载(节点链接的实现已安装,`unknown-activity` 项)。
两面都清零才可激活;图照画照存,未装载不拦保存。
系统隐性上下文(审批 `verdict`、放行选口 `outcome` 的判据注入、起单载荷穿透)
由引擎决定,不进设计者的编排面。
- **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占 - **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占
(同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。 (同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。
起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。 起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。
@@ -55,8 +62,8 @@
一条都没中而该节点确有出边 = **死路**,发 `route-dead-end` 事件 一条都没中而该节点确有出边 = **死路**,发 `route-dead-end` 事件
(哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值)。 (哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值)。
审批点的裁决(`verdict`)由引擎注入判据,两个去向就是它自己的两条条件出边 审批点的裁决(`verdict`)由引擎注入判据,两个去向就是它自己的两条条件出边
- **异或汇合不等**(`joinReady`):互斥分支结构上只会来一条,等齐 = 等一个不可能 - **异或汇合不等**:互斥分支结构上只会来一条,等齐 = 等一个不可能到来的分支,
到来的分支,流会无声卡死;AND 回齐逻辑仅剩历史版本的并行网关在用,容忍分支以「已消化」计 流会无声卡死 —— 汇合等待机制已随网关整体摘除,容忍分支消化后直接推进
- **流程层不自动重试活动**(过程内重试归 Harness); - **流程层不自动重试活动**(过程内重试归 Harness);
流级重做一律人裁决后**重开新任务实例** 流级重做一律人裁决后**重开新任务实例**
- **驳回走向声明式**:审批点的驳回按 `reject` 声明回指定节点重开新实例,不由临场决定 - **驳回走向声明式**:审批点的驳回按 `reject` 声明回指定节点重开新实例,不由临场决定
@@ -4,6 +4,7 @@
* 这条曾经拿 `layout.col` 排,在**回形走线**的图上读错过:MVP 图第 0 行左→右、 * 这条曾经拿 `layout.col` 排,在**回形走线**的图上读错过:MVP 图第 0 行左→右、
* 第 1 行右→左、第 2 行折回最左,A7 的列位跟 A1 并列,链就读成 A1 → A7 → A2 → A6 → A3。 * 第 1 行右→左、第 2 行折回最左,A7 的列位跟 A1 并列,链就读成 A1 → A7 → A2 → A6 → A3。
* 所以这里的第一个用例就是那张回形图 —— 保证它只能读作 A1 → … → A7。 * 所以这里的第一个用例就是那张回形图 —— 保证它只能读作 A1 → … → A7。
* (图形状 = 现行无网关模型:分支是条件出边,条件对拓扑序无影响,这里一律省略。)
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Edge, ProcessNode, ProcessPackage } from '@contracts'; import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
@@ -21,16 +22,6 @@ const act = (id: string, col: number, row: number): ProcessNode =>
layout: { row, col }, layout: { row, col },
}) as never; }) as never;
const gw = (id: string, mode: 'fork' | 'join', col: number, row: number): ProcessNode =>
({
id,
kind: 'gateway',
label: id,
gateway: 'exclusive',
mode,
layout: { row, col },
}) as never;
const edge = (from: string, to: string, reopen = false): Edge => const edge = (from: string, to: string, reopen = false): Edge =>
({ id: `e-${from}-${to}`, from, to, mapping: [], reopen }) as never; ({ id: `e-${from}-${to}`, from, to, mapping: [], reopen }) as never;
@@ -54,40 +45,34 @@ const acts = (p: ProcessPackage): string[] =>
.map((n) => n.id); .map((n) => n.id);
describe('advanceOrder', () => { describe('advanceOrder', () => {
/** MVP 图原样:col 是蛇形列位 —— A7(col 1)与 A1(col 1)并列,A6(col 3)与 A2(col 3)并列 */ /** MVP 图原样(无网关:分支即多出边):col 是蛇形列位 —— A7(col 1)与 A1(col 1)并列 */
const serpentine = pkg( const serpentine = pkg(
[ [
{ id: 'start', kind: 'start', label: '询价', layout: { row: 0, col: 0 } } as never, { id: 'start', kind: 'start', label: '询价', layout: { row: 0, col: 0 } } as never,
act('A1', 1, 0), act('A1', 1, 0),
gw('G1', 'fork', 2, 0), act('A2', 2, 0),
act('A2', 3, 0), act('A3', 3, 0),
gw('G2', 'fork', 4, 0), act('A4', 4, 1),
act('A3', 5, 0), act('A5', 3, 1),
gw('G2J', 'join', 6, 0), act('A6', 2, 1),
act('A4', 7, 0),
act('A5', 6, 1),
act('A6', 3, 1),
gw('G4', 'fork', 2, 1),
act('A7', 1, 2), act('A7', 1, 2),
{ id: 'end_won', kind: 'end', label: '成交', layout: { row: 1, col: 0 } } as never, { id: 'end_won', kind: 'end', label: '成交', layout: { row: 1, col: 0 } } as never,
{ id: 'end_arch', kind: 'end', label: '归档', layout: { row: 2, col: 0 } } as never, { id: 'end_arch', kind: 'end', label: '归档', layout: { row: 2, col: 0 } } as never,
], ],
[ [
edge('start', 'A1'), edge('start', 'A1'),
edge('A1', 'G1'), edge('A1', 'A2'),
edge('G1', 'A2'), edge('A1', 'A3'),
edge('G1', 'G2'), edge('A1', 'A4'),
edge('A2', 'G2'), edge('A2', 'A3'),
edge('G2', 'A3'), edge('A2', 'A4'),
edge('G2', 'G2J'), edge('A3', 'A4'),
edge('A3', 'G2J'),
edge('G2J', 'A4'),
edge('A4', 'A5'), edge('A4', 'A5'),
edge('A5', 'A6'), edge('A5', 'A6'),
edge('A6', 'G4'), edge('A5', 'A4', true), // 回边:驳回退回重新定价
edge('G4', 'end_won'), edge('A6', 'end_won'),
edge('G4', 'A4', true), // 回边:有异议退回重新定价 edge('A6', 'A4', true), // 回边:有异议退回重新定价
edge('G4', 'A7'), edge('A6', 'A7'),
edge('A7', 'end_arch'), edge('A7', 'end_arch'),
], ],
); );
@@ -96,12 +81,10 @@ describe('advanceOrder', () => {
expect(acts(serpentine)).toEqual(['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7']); expect(acts(serpentine)).toEqual(['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7']);
}); });
it('起点在最前、网关按边落位、两个终点都在 A7 之后', () => { it('起点在最前,两个终点都在末段活动之后', () => {
const order = ids(serpentine); const order = ids(serpentine);
expect(order[0]).toBe('start'); expect(order[0]).toBe('start');
expect(order.indexOf('G1')).toBeGreaterThan(order.indexOf('A1')); // 两个终点谁在最后没有意义(都是终态),但都必须在产出它的活动之后
expect(order.indexOf('G2J')).toBeGreaterThan(order.indexOf('A3'));
// 两个终点谁在最后没有意义(都是终态),但都必须在最后一个活动之后
expect(order.indexOf('end_won')).toBeGreaterThan(order.indexOf('A6')); expect(order.indexOf('end_won')).toBeGreaterThan(order.indexOf('A6'));
expect(order.indexOf('end_arch')).toBeGreaterThan(order.indexOf('A7')); expect(order.indexOf('end_arch')).toBeGreaterThan(order.indexOf('A7'));
}); });
@@ -125,27 +108,26 @@ describe('advanceOrder', () => {
expect(acts(pkg(nodes, [...forward, edge('AB', 'AZ')]))).toEqual(['AB', 'AZ']); expect(acts(pkg(nodes, [...forward, edge('AB', 'AZ')]))).toEqual(['AB', 'AZ']);
}); });
it('真并行支按编号定序 —— 同一个包在谁手上都读作同一条链', () => { it('互斥分支在图上本无先后 —— 链按编号定序,同一个包在谁手上都读作同一条链', () => {
const parallel = pkg( const branchy = pkg(
[ [
{ id: 'start', kind: 'start', label: '起', layout: { row: 0, col: 0 } } as never, { id: 'start', kind: 'start', label: '起', layout: { row: 0, col: 0 } } as never,
gw('F', 'fork', 1, 0), act('A0', 1, 0),
act('AZ', 2, 0), act('AZ', 2, 0),
act('AB', 2, 1), act('AB', 2, 1),
gw('J', 'join', 3, 0), { id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 3 } } as never,
{ id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 4 } } as never,
], ],
[ [
edge('start', 'F'), // A0 的两条互斥出边(条件对拓扑序无影响,省略)—— 书写顺序 AZ 在前
edge('F', 'AZ'), edge('start', 'A0'),
edge('F', 'AB'), edge('A0', 'AZ'),
edge('AZ', 'J'), edge('A0', 'AB'),
edge('AB', 'J'), edge('AZ', 'end'),
edge('J', 'end'), edge('AB', 'end'),
], ],
); );
// 书写顺序是 AZ 在前,但编号 AB < AZ —— 链按编号读 // 书写顺序是 AZ 在前,但编号 AB < AZ —— 链按编号读
expect(acts(parallel)).toEqual(['AB', 'AZ']); expect(acts(branchy)).toEqual(['A0', 'AB', 'AZ']);
}); });
it('不可达节点不丢 —— 按编号缀在末尾', () => { it('不可达节点不丢 —— 按编号缀在末尾', () => {
@@ -1,8 +1,6 @@
/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */ /** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts';
import type { ProcessPackage } from '@contracts'; import type { ProcessPackage } from '@contracts';
import { joinReady } from '../internal/domain/routing.ts';
import { checkClosure } from '../internal/domain/closure.ts'; import { checkClosure } from '../internal/domain/closure.ts';
import type { ActivityContractFace } from '../internal/domain/closure.ts'; import type { ActivityContractFace } from '../internal/domain/closure.ts';
import { import {
@@ -126,25 +124,6 @@ describe('checkClosure —— 不闭合不得激活', () => {
expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0); expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0);
}); });
/*
* 网关已废除(2026-07-28):含网关的定义一律违规 —— 历史版本仅可读,不可激活。
* 分支改由源节点的条件出边表达,见下一用例。
*/
it('网关节点即违规 —— gateway-deprecated,历史版本不可激活', () => {
const withGateway = pkg({
nodes: [
...pkg().nodes,
{ id: 'G2' as never, kind: 'gateway', label: 'G2 料号分流', gateway: 'exclusive', mode: 'fork' },
],
edges: [
...pkg().edges,
{ id: 'g1', from: 'A4' as never, to: 'G2' as never, mapping: [], reopen: false },
{ id: 'g2', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false },
],
});
const violations = checkClosure({ pkg: withGateway, contracts, positions }).violations;
expect(violations.filter((v) => v.kind === 'gateway-deprecated').map((v) => v.nodeId)).toEqual(['G2']);
});
it('多出边异或分支:两条无条件出边 = 都想当默认 —— multi-default-edge', () => { it('多出边异或分支:两条无条件出边 = 都想当默认 —— multi-default-edge', () => {
const twoDefaults = pkg({ const twoDefaults = pkg({
@@ -340,92 +319,8 @@ describe('失败走向的消化与重开', () => {
}); });
}); });
describe('joinReady —— 网关直通道不参与等待', () => {
const pkg = processPackageSchema.parse({
id: 'p-join',
name: '汇合测试',
version: 1,
state: 'active',
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ kind: 'start', id: 'S', label: '开始' },
{ kind: 'gateway', id: 'F', label: '分叉', gateway: 'parallel', mode: 'fork' },
{ kind: 'activity', id: 'X', label: 'X', activityId: 'a-x', owner: { kind: 'position', position: 'BD' } },
{ kind: 'activity', id: 'Y', label: 'Y', activityId: 'a-y', owner: { kind: 'position', position: 'BD' } },
{ kind: 'gateway', id: 'J', label: '汇合', gateway: 'parallel', mode: 'join' },
{ kind: 'end', id: 'E', label: '结束' },
],
edges: [
{ id: 'e1', from: 'S', to: 'F', mapping: [], reopen: false },
{ id: 'e2', from: 'F', to: 'X', mapping: [], reopen: false },
{ id: 'e3', from: 'F', to: 'Y', mapping: [], reopen: false },
// 分叉直连汇合的**直通道**(TYPE-A 快路):来源是网关,永远不会有实例
{ id: 'e4', from: 'F', to: 'J', mapping: [], reopen: false },
{ id: 'e5', from: 'X', to: 'J', mapping: [], reopen: false },
{ id: 'e6', from: 'Y', to: 'J', mapping: [], reopen: false },
{ id: 'e7', from: 'J', to: 'E', mapping: [], reopen: false },
],
variables: [],
});
it('两条责任分支都收敛 → 回齐(网关直通道不算一票)', () => {
expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).toBe(true);
});
it('一条容忍消化、一条收敛 → 回齐', () => {
expect(joinReady(pkg, 'J', new Set(['X']), new Set(['Y']))).toBe(true);
});
it('还差一条 → 不回齐', () => {
expect(joinReady(pkg, 'J', new Set(['X']), new Set())).toBe(false);
});
it('把网关来源也算进去就会永远等不到 —— 这是回归守卫', () => {
// 若实现改回「全部入边都要收敛」,上面第一条断言必然转 false
expect(joinReady(pkg, 'J', new Set(['X', 'Y']), new Set())).not.toBe(false);
});
});
/* /*
* 异或汇合不等 —— 这条曾经让流无声停住。 * joinReady 已随网关一并摘除(2026-07-28 第二轮):多入边即异或汇合 —— 上游互斥,
* * 只会来一条,**没有「等回齐」这回事**;AND 并行待「并行组」声明时再带自己的汇合语义。
* G2J(寻源汇合)是异或:「需要寻源」走 A3、「不需要」直通,两条互斥、只来一条。 * 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。
* 而 `joinReady` 原本按并行汇合写(全部责任分支收敛才放行),于是在「不需要寻源」那条路上
* 永远在等 A3 —— 一个结构上不会激活的分支。症状最坑的地方是**连死路事件都没有**:
* 边是选中了的,只是在汇合判定里被 continue 跳过,从外面看就是 A1 办结后什么都不再发生。
*/ */
describe('joinReady —— 异或汇合只来一条,不等', () => {
const xor = processPackageSchema.parse({
id: 'p-xor-join',
name: '异或汇合测试',
version: 1,
state: 'active',
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ kind: 'start', id: 'S', label: '开始' },
{ kind: 'gateway', id: 'G', label: '要不要绕', gateway: 'exclusive', mode: 'fork', basis: '判据' },
{ kind: 'activity', id: 'X', label: 'X', activityId: 'a-x', owner: { kind: 'position', position: 'BD' } },
{ kind: 'gateway', id: 'J', label: '汇合', gateway: 'exclusive', mode: 'join' },
{ kind: 'end', id: 'E', label: '结束' },
],
edges: [
{ id: 'e1', from: 'S', to: 'G', mapping: [], reopen: false },
{ id: 'e2', from: 'G', to: 'X', condition: 'go = true', mapping: [], reopen: false },
// 直通:不绕 X,从网关直接进汇合
{ id: 'e3', from: 'G', to: 'J', condition: 'go = false', mapping: [], reopen: false },
{ id: 'e4', from: 'X', to: 'J', mapping: [], reopen: false },
{ id: 'e5', from: 'J', to: 'E', mapping: [], reopen: false },
],
variables: [],
});
it('走直通那条:X 从未激活,汇合照样放行', () => {
expect(joinReady(xor, 'J', new Set(), new Set())).toBe(true);
});
it('走 X 那条:X 收敛后放行', () => {
expect(joinReady(xor, 'J', new Set(['X']), new Set())).toBe(true);
});
// 并行汇合「该等的还得等」由上一个 describe 的「还差一条 → 不回齐」守着,不在这儿重复
});
-1
View File
@@ -40,7 +40,6 @@ export {
advanceOrder, advanceOrder,
assemble, assemble,
dispatchOf, dispatchOf,
joinReady,
rejectRouteOf, rejectRouteOf,
selectEdges, selectEdges,
startNodeOf, startNodeOf,
@@ -6,8 +6,6 @@
* 存在未完成边、未满足必填或类型冲突的流程包**存不进来**。 * 存在未完成边、未满足必填或类型冲突的流程包**存不进来**。
* 多出边即异或分支(恰取一条,默认兜底至多一条);多入边即异或汇合。」 * 多出边即异或分支(恰取一条,默认兜底至多一条);多入边即异或汇合。」
* *
* 网关已废除(2026-07-28):schema 仍收 gateway 只为读历史 —— 见 checkGateway。
*
* 原文是「可存草稿、不得发布」——**已不成立**:没有草稿态也没有发布门, * 原文是「可存草稿、不得发布」——**已不成立**:没有草稿态也没有发布门,
* 闸落在保存上(严格检查 + 保存即生效)。画布上的半成品留在浏览器里,存不进库。 * 闸落在保存上(严格检查 + 保存即生效)。画布上的半成品留在浏览器里,存不进库。
* *
@@ -68,10 +66,6 @@ export function checkClosure(input: ClosureInput): ClosureReport {
message: `审批点「${node.label}」的岗位不存在:${node.approver.position}`, message: `审批点「${node.label}」的岗位不存在:${node.approver.position}`,
}); });
} }
if (node.kind === 'gateway') {
violations.push(...checkGateway(node, incoming.get(node.id) ?? [], outgoing.get(node.id) ?? []));
}
/* /*
* 多出边 = 异或分支(恰取一条):条件命中优先,无条件边作默认兜底。 * 多出边 = 异或分支(恰取一条):条件命中优先,无条件边作默认兜底。
* 默认兜底**至多一条** —— 两条无条件出边就是「都想当默认」,运行时按声明序 * 默认兜底**至多一条** —— 两条无条件出边就是「都想当默认」,运行时按声明序
@@ -169,28 +163,6 @@ function checkActivityNode(
return out; return out;
} }
function checkGateway(
node: Extract<ProcessNode, { kind: 'gateway' }>,
inbound: readonly Edge[],
outbound: readonly Edge[],
): ClosureViolation[] {
void inbound;
void outbound;
/*
* **网关已废除**(产品决策 2026-07-28):分支 = 源节点的条件出边,汇合 = 多入边异或。
* schema 仍收 gateway 只为**读历史版本** —— 在途工作流照走(运行时兼容),
* 但含网关的定义不再可激活:这里判违规,激活闸(不闭合不得激活)就会拦下。
* 想复用旧图,在生效版上把网关的出边改画成源活动的条件出边即可。
*/
return [
{
kind: 'gateway-deprecated',
nodeId: node.id,
message: `网关「${node.label}」已废除 —— 把分支写成上游节点的条件出边(历史版本仅可读,不可激活)`,
},
];
}
/** /**
* 容忍(仅限 AND 分支):静态校验其**下游映射不得依赖该分支的必填字段**(OS 03 失败走向)。 * 容忍(仅限 AND 分支):静态校验其**下游映射不得依赖该分支的必填字段**(OS 03 失败走向)。
*/ */
@@ -2,7 +2,7 @@
* 流程引擎的确定性流转 —— 纯逻辑,零 IO、**零 LLM**(编码规范 §5.3;语义出处 OS 04)。 * 流程引擎的确定性流转 —— 纯逻辑,零 IO、**零 LLM**(编码规范 §5.3;语义出处 OS 04)。
* *
* 「流程引擎 —— 确定性流转:接收调用接口的起单、按结构调度节点、把审批点卡到人的工位、 * 「流程引擎 —— 确定性流转:接收调用接口的起单、按结构调度节点、把审批点卡到人的工位、
* 执行网关的分合、把每次流转记入事件。**零 LLM、零临场**。」 * 按条件出边选路、把每次流转记入事件。**零 LLM、零临场**。」
*/ */
import type { Edge, ProcessNode, ProcessPackage } from '@contracts'; import type { Edge, ProcessNode, ProcessPackage } from '@contracts';
@@ -72,66 +72,20 @@ export function selectEdges(
scope: Readonly<Record<string, unknown>>, scope: Readonly<Record<string, unknown>>,
evaluate: PredicateEvaluator, evaluate: PredicateEvaluator,
): Edge[] { ): Edge[] {
const node = pkg.nodes.find((n) => n.id === fromNodeId);
const outgoing = pkg.edges.filter((e) => e.from === fromNodeId); const outgoing = pkg.edges.filter((e) => e.from === fromNodeId);
/* /*
* **多出边一律异或,不分节点种类**(产品决策 2026-07-28:网关去节点化)。 * **多出边一律异或,不分节点种类**(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。
* * 条件命中优先(按声明序),无条件边作默认兜底,恰取一条。
* 分支不再是网关节点的专利 —— 活动/审批点自己的多条条件出边就是分支, * 并行(AND)没有声明机制 —— 要并行时以「并行组」加回(对应 `.parallel`,债 M5-4)。
* 判据读的是它自己的出参(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。
* 语义与从前的异或网关逐字相同:条件命中优先(按声明序),无条件边作默认兜底,
* 恰取一条。并行(AND)没有节点了也没有声明机制 —— 要并行时以「并行组」声明加回
* (对应 Mastra 的 `.parallel` 结构块),见债务簿。
*
* 遗留的网关节点(历史版本里还有)走同一条逻辑 —— 异或网关的语义本来就是这个;
* 并行网关分叉的「全发」不再支持,历史包若有,激活时已被闭合校验拦下。
*/ */
if (outgoing.length <= 1) { if (outgoing.length <= 1) return outgoing;
// 并行网关的遗留直通不受损:单出边不判条件,原样走
void node;
return outgoing;
}
const hit = outgoing.find((e) => e.condition !== undefined && evaluate(e.condition, scope)); const hit = outgoing.find((e) => e.condition !== undefined && evaluate(e.condition, scope));
const fallback = outgoing.find((e) => e.condition === undefined); const fallback = outgoing.find((e) => e.condition === undefined);
const chosen = hit ?? fallback; const chosen = hit ?? fallback;
return chosen === undefined ? [] : [chosen]; return chosen === undefined ? [] : [chosen];
} }
/**
* AND 汇合是否回齐:全部**责任分支**的来源都已收敛。
*
* 网关本身不是责任单元、永远不会有实例,所以来源是网关的入边(分叉直连汇合的**直通道**)
* 不参与等待 —— 分叉一发生它就算过了。若把它也算进去,汇合会永远等一个不可能到来的
* 「网关收敛」,整条流卡死在这里。
*/
export function joinReady(
pkg: ProcessPackage,
joinNodeId: string,
converged: ReadonlySet<string>,
tolerated: ReadonlySet<string>,
): boolean {
const node = pkg.nodes.find((n) => n.id === joinNodeId);
/*
* **异或汇合不等。**
*
* 上面那句「回齐」是按并行汇合写的:AND 分叉出去的每一支都要跑,所以汇合处要等齐。
* 异或汇合是另一回事 —— 两条分支互斥,**结构上只会来一条**,另一条永远不激活。
* 拿「全部入边都收敛」去要求它,就是在等一个不可能到来的分支:
* 实测 G2J(寻源汇合)在「不需要寻源」这条路上永远 false,流无声停在 G2J,
* 而且**连死路事件都没有** —— 边是选中了的,只是在汇合判定里被 continue 跳过,
* 从外面看就是 A1 办结后什么都不再发生。
*/
if (node?.kind === 'gateway' && node.gateway === 'exclusive') return true;
const isGateway = (nodeId: string): boolean =>
pkg.nodes.find((n) => n.id === nodeId)?.kind === 'gateway';
const inbound = pkg.edges.filter((e) => e.to === joinNodeId && !isGateway(e.from));
// 容忍分支以「已消化」计,不阻汇合
return inbound.every((e) => converged.has(e.from) || tolerated.has(e.from));
}
/** 节点该被怎么调度 */ /** 节点该被怎么调度 */
export function dispatchOf(node: ProcessNode, payload: Record<string, unknown>): Dispatch | undefined { export function dispatchOf(node: ProcessNode, payload: Record<string, unknown>): Dispatch | undefined {
if (node.kind === 'activity') return { kind: 'start-task', node, payload }; if (node.kind === 'activity') return { kind: 'start-task', node, payload };
@@ -23,7 +23,7 @@ import type {
import { checkClosure } from '../domain/closure.ts'; import { checkClosure } from '../domain/closure.ts';
import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts'; import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts';
import type { NodeInstance } from '../domain/owner-resolution.ts'; import type { NodeInstance } from '../domain/owner-resolution.ts';
import { assemble, dispatchOf, joinReady, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts'; import { assemble, dispatchOf, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts';
export type ProcessServiceDeps = { export type ProcessServiceDeps = {
readonly store: ProcessStore; readonly store: ProcessStore;
@@ -229,23 +229,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
const target = pkg.nodes.find((n) => n.id === edge.to); const target = pkg.nodes.find((n) => n.id === edge.to);
if (target === undefined) continue; if (target === undefined) continue;
if (target.kind === 'gateway' && target.mode === 'join') {
const instances = await deps.store.listInstances(record.id);
const converged = new Set(instances.filter((i) => i.terminal === 'released').map((i) => i.nodeId));
const tolerated = new Set(instances.filter((i) => i.failureDigested === true).map((i) => i.nodeId));
// 未回齐就等着 —— 别的分支到了会再来一次
if (!joinReady(pkg, target.id, converged, tolerated)) continue;
}
const assembled = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression);
/* /*
* 网关不是责任单元,**按到达的东西路由**:它没有入参契约要满足, * 多入边即异或汇合:上游互斥,只会来一条 —— **没有「等回齐」这回事**。
* 上游的输出口载荷要原样穿过去,判据才读得到(如 G4 读 outcome)。 * (等待逻辑随网关一起摘除;AND 并行待「并行组」声明时再带自己的汇合语义。)
* 若只给边映射装配出来的那份,无映射的边就把判据依据整个丢掉, * 节点只收**装配后的**载荷:活动与审批点都有契约,不被上游多余字段污染;
* 异或网关会一条分支都选不中,整条流悄无声息地停住。 * 判据要用的隐性上下文(审批 verdict、放行选口 outcome)由系统注入判据作用域,不走映射。
* 活动与审批点仍只收**装配后的**载荷 —— 它们有契约,不该被上游多余字段污染。
*/ */
const arriving = target.kind === 'gateway' ? { ...payload, ...assembled } : assembled; const arriving = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression);
try { try {
await enter(record, pkg, target, arriving); await enter(record, pkg, target, arriving);
} catch (error) { } catch (error) {
@@ -450,8 +440,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
if (disposition.kind === 'tolerate') { if (disposition.kind === 'tolerate') {
/* /*
* 容忍 = 这条支线的失败被吸收,**流继续往下走**。 * 容忍 = 这条支线的失败被吸收,**流继续往下走**。
* 只把实例标成「已消化」而不往下推,汇合就永远等不到它: * 光把实例标成「已消化」而不往下推,这条线就断在这儿 —— 下游谁也不会再被叫起。
* `joinReady` 认 tolerated,可没人再去问一次它够不够齐。
* 载荷为空 —— 容忍分支本就没有产出,下游的必填映射不得依赖它(闭合校验已拦)。 * 载荷为空 —— 容忍分支本就没有产出,下游的必填映射不得依赖它(闭合校验已拦)。
*/ */
await advanceFrom(record, pkg, nodeId, {}); await advanceFrom(record, pkg, nodeId, {});
+5
View File
@@ -562,6 +562,11 @@
.g-ex { cursor: pointer; border-left-color: #b0413e; } .g-ex { cursor: pointer; border-left-color: #b0413e; }
.g-ex > b { font-size: 11px; } .g-ex > b { font-size: 11px; }
.g-ex .msg { flex: 1; min-width: 0; font-size: 10.5px; line-height: 1.55; letter-spacing: 0; text-transform: none; color: #3a4452; } .g-ex .msg { flex: 1; min-width: 0; font-size: 10.5px; line-height: 1.55; letter-spacing: 0; text-transform: none; color: #3a4452; }
.g-exfacet { display: flex; align-items: center; gap: 6px; padding: 6px 2px 2px; font: 600 9.5px/1 var(--mono); letter-spacing: 0.14em; text-transform: uppercase; color: var(--sh-steel, #46586a); }
.g-exfacet.ok { color: #2e7d4f; }
.g-maprow { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.g-maprow .g-input, .g-maprow .g-select { flex: 1 1 90px; min-width: 70px; }
.g-maparr { color: var(--sh-steel, #46586a); font-weight: 700; }
.g-exboard-empty { padding: 16px 18px; font-family: var(--sh-mono); font-size: 10px; line-height: 1.9; color: var(--sh-muted); } .g-exboard-empty { padding: 16px 18px; font-family: var(--sh-mono); font-size: 10px; line-height: 1.9; color: var(--sh-muted); }
/* 异常板高度随内容走,不被右侧属性卡拉高 —— 三条异常配 900px 空板才是脏 */ /* 异常板高度随内容走,不被右侧属性卡拉高 —— 三条异常配 900px 空板才是脏 */
@@ -10,7 +10,7 @@
* · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线 * · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线
* · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响 * · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响
*/ */
import { useMemo, useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts'; import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts';
import { processPackageSchema, saveProcessRoute, setProcessStateRoute, validateProcessRoute } from '@contracts'; import { processPackageSchema, saveProcessRoute, setProcessStateRoute, validateProcessRoute } from '@contracts';
@@ -39,7 +39,7 @@ import type { GovActivities } from '@web-shared/gov/gov-api.ts';
import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx'; import { FlowFigureBlock } from '@web-shared/gov/flow/FlowFigure.tsx';
import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts'; import { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.ts';
import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts'; import { mergeGraphIntoPackage } from '@web-shared/gov/flow/graph-to-pkg.ts';
import type { FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts'; import type { FlowEdge, FlowGraph, FlowNode } from '@web-shared/gov/flow/flow-layout.ts';
import type { SpPosition } from '@web-shared/gov/flow/support.tsx'; import type { SpPosition } from '@web-shared/gov/flow/support.tsx';
import { TriggerRulesHome } from '@features/gov-trigger'; import { TriggerRulesHome } from '@features/gov-trigger';
@@ -66,6 +66,151 @@ function flowsOf(packages: readonly ProcessPackage[]) {
}); });
} }
/* ── 边属性:传递契约(条件 + 映射)──────────────────────────────────── */
/** 卡点 id → 去向节点 id(边属性按语义对端认亲:A5→c_reject 即 A5→A4) */
function calloutToOf(graph: FlowGraph | null): Map<string, string> | undefined {
if (graph === null) return undefined;
return new Map(
graph.nodes.filter((n) => n.kind === 'callout' && n.to !== undefined).map((n) => [n.id, n.to!]),
);
}
/** 映射行的工作形状 —— 语义边 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;
function EdgeProps({
edge,
toSem,
onPatch,
}: {
edge: FlowEdge;
toSem: string;
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">
<Prop ico="git-branch" title={`条件 · ${edge.from} → ${toSem}`} extra="多出边异或:命中即走;留空 = 默认兜底">
<Field k="S-FEEL 条件" hint="如 needSourcing = true;可用 and / or 连接。留空即无条件(默认边,至多一条)">
<input
className="g-input mono"
value={edge.condition ?? ''}
placeholder="留空 = 默认兜底"
onChange={(e) => onPatch((x) => ({ ...x, condition: e.target.value }))}
/>
</Field>
<Field k="边标签" hint="画在线旁的注记,辅助阅读,不参与判断">
<input
className="g-input"
value={edge.label ?? ''}
placeholder="如:需要选型"
onChange={(e) =>
onPatch((x) => {
const { label: _drop, ...rest } = x;
return e.target.value === '' ? rest : { ...rest, label: e.target.value };
})
}
/>
</Field>
</Prop>
<Prop ico="git-merge" title="映射 · 载荷装配" extra="下游字段 ← 来源;必填映射齐了这条边才闭合">
{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"
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"
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>
);
}
/* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */ /* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */
export function WorkflowsHome({ tab, nav }: HomeProps) { export function WorkflowsHome({ tab, nav }: HomeProps) {
@@ -218,7 +363,6 @@ function FlowShelf({ nav }: { nav: Nav }) {
const c = { const c = {
activity: shown.nodes.filter((n) => n.kind === 'activity').length, activity: shown.nodes.filter((n) => n.kind === 'activity').length,
approval: shown.nodes.filter((n) => n.kind === 'approval').length, approval: shown.nodes.filter((n) => n.kind === 'approval').length,
gateway: shown.nodes.filter((n) => n.kind === 'gateway').length,
}; };
return ( return (
<button <button
@@ -252,7 +396,7 @@ function FlowShelf({ nav }: { nav: Nav }) {
审批 {c.approval} 审批 {c.approval}
</Chip> </Chip>
<Chip tone="mute" ico="git-branch"> <Chip tone="mute" ico="git-branch">
网关 {c.gateway} 边 {shown.edges.length}
</Chip> </Chip>
</span> </span>
<span className="g-tile-f"> <span className="g-tile-f">
@@ -351,11 +495,11 @@ function FlowLab({
* 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 —— * 任何版本都可编辑:保存恒存为**未生效**的新版本,谁也伤不着 ——
* 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。 * 在旧版上改就是「以旧版为底另起一版」。生效切换只在激活那一下。
*/ */
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */ /** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
const [graph, setGraph] = useState<FlowGraph | null>(() => packageToGraph(pkg, [], paradigms)); const [graph, setGraph] = useState<FlowGraph | null>(() => packageToGraph(pkg, []));
const [sel, setSel] = useState<string | null>(null); const [sel, setSel] = useState<string | null>(null);
/** 边选中态:graph.edges 的下标(与节点选中互斥 —— 属性卡一次编辑一件事) */
const [selEdge, setSelEdge] = useState<number | null>(null);
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [report, setReport] = useState<ClosureReport | undefined>(undefined); const [report, setReport] = useState<ClosureReport | undefined>(undefined);
const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined); const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined);
@@ -369,7 +513,8 @@ function FlowLab({
? undefined ? undefined
: activities.find( : activities.find(
(a) => (a) =>
(semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId) || a.name === node.label, (node.act !== undefined && a.activityId === node.act) ||
(node.act === undefined && semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId),
); );
// 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告 // 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告
@@ -390,28 +535,14 @@ function FlowLab({
n.kind === 'activity' n.kind === 'activity'
? n.flavor === 'approval' ? n.flavor === 'approval'
? '审批点' ? '审批点'
: n.flavor === 'case' : '活动点'
? '案例活动 · loop'
: '标准活动 · flow'
: n.kind === 'gateway'
? n.mode === 'and'
? '并行网关'
: '判断网关'
: n.kind === 'callout' : n.kind === 'callout'
? '卡点' ? '卡点'
: n.kind === 'start' : n.kind === 'start'
? '调用接口' ? '调用接口'
: '显式终点'; : '显式终点';
const kindTone = (n: FlowNode) => const kindTone = (n: FlowNode) =>
n.kind === 'activity' n.kind === 'activity' ? (n.flavor === 'approval' ? 'warn' : 'auto') : n.kind === 'callout' ? 'danger' : 'mute';
? n.flavor === 'approval'
? 'warn'
: n.flavor === 'case'
? 'collab'
: 'auto'
: n.kind === 'callout'
? 'danger'
: 'mute';
// 画布上的选中态:diagram-js 给每个形状挂了 data-element-id,外挂一个类,不改画布件 // 画布上的选中态:diagram-js 给每个形状挂了 data-element-id,外挂一个类,不改画布件
useEffect(() => { useEffect(() => {
@@ -694,16 +825,16 @@ function FlowLab({
<FlowFigureBlock <FlowFigureBlock
key={`${pkg.id}@${pkg.version}`} key={`${pkg.id}@${pkg.version}`}
title={`${pkg.name} v${pkg.version}`} title={`${pkg.name} v${pkg.version}`}
oneLiner={`蓝 = 标准活动 · 绿 = 案例活动 · 琥珀 = 审批点 · 红 = 卡点`} oneLiner={`蓝 = 活动点 · 琥珀 = 审批点 · 红 = 卡点`}
graph={graph} graph={graph}
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)} drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
/* /*
* 画布只画**编排逻辑**:节点与网关的名字、连线;边上的字是辅助阅读的注记。 * 画布只画**编排逻辑**:节点的名字、连线的顺序;边上的字是辅助阅读的注记。
* 图上真正生效可编辑的只有**名字** —— 不传 positions,双击就不出岗位一栏。 * 图上真正生效可编辑的只有**名字** —— 不传 positions,双击就不出岗位一栏。
* 岗位、判据、映射这些闭环要素在**节点属性**里决定;分配到人是运营的事,这里不碰。 * 岗位、判据、映射这些闭环要素在**节点属性**里决定;分配到人是运营的事,这里不碰。
*/ */
canEdit canEdit
onNodeClick={(nid) => setSel(nid)} onNodeClick={(nid) => { setSel(nid); setSelEdge(null); }}
onEditNodeText={async (nid, p2) => { onEditNodeText={async (nid, p2) => {
setGraph((g) => setGraph((g) =>
g === null g === null
@@ -727,17 +858,16 @@ function FlowLab({
* 清零了激活按钮才亮 —— 完整性闸在激活,不在画图。 * 清零了激活按钮才亮 —— 完整性闸在激活,不在画图。
*/} */}
<aside className="g-vercard g-exboard"> <aside className="g-vercard g-exboard">
<div className="g-vercard-h">异常 · 看板</div> <div className="g-vercard-h">激活 · 双判据</div>
<div className="g-vercard-list"> <div className="g-vercard-list">
{activeReport === undefined ? ( {activeReport === undefined ? (
<div className="g-exboard-empty">校验中…</div> <div className="g-exboard-empty">校验中…</div>
) : activeReport.violations.length === 0 ? (
<div className="g-exboard-empty">
没有异常 —— v{pkg.version} 的编排闭合了。
{pkg.version === activeVersion ? '这一版正在生效。' : '版本行的「激活」已亮,点它即可上线。'}
</div>
) : ( ) : (
activeReport.violations.map((v, i) => { (() => {
/* 能不能激活看两面:①图编排闭合完善 ②活动装载。unknown-activity 归装载,其余归编排。 */
const loading = activeReport.violations.filter((v) => v.kind === 'unknown-activity');
const drawing = activeReport.violations.filter((v) => v.kind !== 'unknown-activity');
const row = (v: (typeof activeReport.violations)[number], i: number) => {
const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to; const nid = v.nodeId ?? pkg.edges.find((e) => e.id === v.edgeId)?.to;
return ( return (
<div <div
@@ -746,20 +876,41 @@ function FlowLab({
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => { onClick={() => {
if (nid !== undefined) setSel(String(nid)); if (nid !== undefined) { setSel(String(nid)); setSelEdge(null); }
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && nid !== undefined) setSel(String(nid)); if (e.key === 'Enter' && nid !== undefined) { setSel(String(nid)); setSelEdge(null); }
}} }}
> >
{nid !== undefined && <b>{String(nid)}</b>} {nid !== undefined && <b>{String(nid)}</b>}
<span className="msg">{v.message}</span> <span className="msg">{v.message}</span>
</div> </div>
); );
}) };
return (
<>
<div className={'g-exfacet' + (drawing.length === 0 ? ' ok' : '')}>
<Ico name={drawing.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
图编排 {drawing.length === 0 ? '闭合' : `${drawing.length} 项待补`}
</div>
{drawing.map(row)}
<div className={'g-exfacet' + (loading.length === 0 ? ' ok' : '')}>
<Ico name={loading.length === 0 ? 'circle-check' : 'triangle-alert'} s={12} />
活动装载 {loading.length === 0 ? '齐' : `${loading.length} 个节点未链接实现`}
</div>
{loading.map(row)}
{activeReport.violations.length === 0 && (
<div className="g-exboard-empty">
两面都齐 —— v{pkg.version} 可运行。
{pkg.version === activeVersion ? '这一版正在生效。' : '版本行的「激活」已亮,点它即可上线。'}
</div>
)}
</>
);
})()
)} )}
</div> </div>
<div className="g-vercard-f">点一条异常,定位到它的节点属性;清零后才可激活。</div> <div className="g-vercard-f">点一条定位到属性去补;两面清零,激活才亮。</div>
</aside> </aside>
{/* 右下:节点属性(图纸卡,与流程图同宽)—— 闭环(岗位/判据/映射)在这里决定 */} {/* 右下:节点属性(图纸卡,与流程图同宽)—— 闭环(岗位/判据/映射)在这里决定 */}
@@ -791,16 +942,47 @@ function FlowLab({
<button <button
key={n.id} key={n.id}
className={`g-pick t-${kindTone(n)}` + (sel === n.id ? ' on' : '')} className={`g-pick t-${kindTone(n)}` + (sel === n.id ? ' on' : '')}
onClick={() => setSel(sel === n.id ? null : n.id)} onClick={() => { setSel(sel === n.id ? null : n.id); setSelEdge(null); }}
> >
<b className="g-mono">{n.id}</b> <b className="g-mono">{n.id}</b>
{n.label} {n.label}
</button> </button>
))} ))}
</div> </div>
{node === null ? ( {/* 边拣选条:边属性 = 传递契约(条件 + 映射)—— 图只定顺序,契约在这儿填 */}
<Note>点画布上的节点(或上面的节点条)选一个 —— 属性就在下面填,网关判据与卡点去向同样在此改。</Note> <div className="g-picker">
) : ( {(graph?.edges ?? []).map((e, i) => {
const toSem = calloutToOf(graph)?.get(e.to) ?? e.to;
const brief = e.condition !== undefined && e.condition !== '' ? ` [${e.condition}]` : '';
return (
<button
key={`${e.from}→${e.to}-${i}`}
className={'g-pick t-mute' + (selEdge === i ? ' on' : '')}
onClick={() => { setSelEdge(selEdge === i ? null : i); setSel(null); }}
>
<b className="g-mono">{e.from}→{toSem}</b>
{(e.label ?? '') + brief}
</button>
);
})}
</div>
{node === null && selEdge === null ? (
<Note>
点画布或上面的拣选条选一件 —— 节点填岗位与链接实现,边填传递契约(条件 + 映射)。
审批裁决(verdict)与放行选口(outcome)由系统注入判据,不用你声明。
</Note>
) : node === null && selEdge !== null && graph !== null ? (
<EdgeProps
edge={graph.edges[selEdge]!}
toSem={calloutToOf(graph)?.get(graph.edges[selEdge]!.to) ?? graph.edges[selEdge]!.to}
onPatch={(f) => {
setGraph((g) =>
g === null ? g : { ...g, edges: g.edges.map((e, i) => (i === selEdge ? f(e) : e)) },
);
setDirty(true);
}}
/>
) : node === null ? null : (
<> <>
<div className="g-props"> <div className="g-props">
<Prop ico="tag" title="身份与命名" extra={kindLabel(node)}> <Prop ico="tag" title="身份与命名" extra={kindLabel(node)}>
@@ -841,34 +1023,21 @@ function FlowLab({
} }
> >
{node.kind === 'activity' && ( {node.kind === 'activity' && (
<Field k="活动类型" hint="决定它在运行时长成什么"> <Field k="节点类型" hint="节点只分两种 —— 实现方式是活动库的事,这里不选">
<Seg <Seg
value={node.flavor ?? 'flow'} value={node.flavor === 'approval' ? 'approval' : 'act'}
options={[ options={[
{ k: 'flow', label: '标准 · flow' }, { k: 'act', label: '活动点' },
{ k: 'case', label: '案例 · loop' },
{ k: 'approval', label: '审批点' }, { k: 'approval', label: '审批点' },
]} ]}
onPick={(k) => onPick={(k) =>
patch((n) => ({ ...n, flavor: k === 'flow' ? undefined : (k as 'case' | 'approval') })) patch((n) => ({ ...n, flavor: k === 'approval' ? ('approval' as const) : undefined }))
} }
/> />
</Field> </Field>
)} )}
{node.kind === 'gateway' && (
<Field k="网关模式" hint="× 判断分流 / + 并行同走">
<Seg
value={node.mode ?? 'x'}
options={[
{ k: 'x', label: '判断 ×' },
{ k: 'and', label: '并行 +' },
]}
onPick={(k) => patch((n) => ({ ...n, mode: k as 'x' | 'and' }))}
/>
</Field>
)}
<Field <Field
k={node.kind === 'gateway' ? '判定岗位' : node.flavor === 'approval' ? '审批岗位' : '责任岗位'} k={node.flavor === 'approval' ? '审批岗位' : '责任岗位'}
hint={node.flavor === 'approval' ? '审批单派给它' : '任务的负责人由它解析而来'} hint={node.flavor === 'approval' ? '审批单派给它' : '任务的负责人由它解析而来'}
> >
<select <select
@@ -919,19 +1088,6 @@ function FlowLab({
/> />
</Prop> </Prop>
)} )}
{node.kind === 'gateway' && (
<Prop ico="shield-check" title="判据" extra="条件用 S-FEEL 子集,静态可检">
<textarea
className="g-ta"
rows={3}
value={node.basis ?? ''}
placeholder="如:台账 90 天内有成交锚点则直接定价,否则先寻源"
onChange={(e) =>
patch((n) => ({ ...n, basis: e.target.value === '' ? undefined : e.target.value }))
}
/>
</Prop>
)}
{node.kind === 'callout' && ( {node.kind === 'callout' && (
<Prop ico="rotate-ccw" title="去向" extra="失败走向 / 驳回去向"> <Prop ico="rotate-ccw" title="去向" extra="失败走向 / 驳回去向">
<Field k="回到节点"> <Field k="回到节点">
@@ -955,15 +1111,29 @@ function FlowLab({
</Prop> </Prop>
)} )}
{node.kind === 'activity' && node.flavor !== 'approval' && ( {node.kind === 'activity' && node.flavor !== 'approval' && (
<Prop ico="boxes" title="引用的活动" extra="活动住库,编排只引用"> <Prop ico="boxes" title="链接活动实现" extra="装载面:编排不含实现,链接决定可运行">
<Field k="实现" hint="活动住库;链好了这节点才算装载(激活判据之二)">
<select
className="g-select"
value={refAct?.activityId ?? ''}
onChange={(e) =>
patch((n) => ({ ...n, act: e.target.value === '' ? undefined : e.target.value }))
}
>
<option value="">未链接 —— 激活会被「活动装载」拦下</option>
{activities.map((a) => (
<option key={a.activityId} value={a.activityId}>
{a.name} · {a.activityId}
</option>
))}
</select>
</Field>
{refAct !== undefined ? ( {refAct !== undefined ? (
<> <>
{/* 实现方式(范式/版本)是装载信息,只读展示 —— 编排面不决定它 */}
<div className="g-prop-ref"> <div className="g-prop-ref">
<Chip <Chip tone={refAct.paradigm === 'loop' ? 'collab' : 'auto'} ico="git-commit">
tone={refAct.paradigm === 'loop' ? 'collab' : 'auto'} {refAct.paradigm === 'loop' ? '实现:案例 loop' : '实现:标准 flow'}
ico={refAct.paradigm === 'loop' ? 'repeat' : 'git-commit'}
>
{refAct.paradigm === 'loop' ? '案例活动 loop' : '标准活动 flow'}
</Chip> </Chip>
<Chip tone="mute"> <Chip tone="mute">
v{refAct.packageVersions[0]?.version ?? '—'} × a v{refAct.packageVersions[0]?.version ?? '—'} × a
@@ -979,7 +1149,7 @@ function FlowLab({
</button> </button>
</> </>
) : ( ) : (
<Note tone="warn">还没对上活动库里已安装的活动 —— 先去活动库安装;保存时会核对契约相容。</Note> <Note tone="warn">还没链接活动库里已安装的实现 —— 图可以照画照存,激活前链上即可。</Note>
)} )}
</Prop> </Prop>
)} )}
+2 -3
View File
@@ -102,10 +102,9 @@ export function FlowFigureBlock({
</span> </span>
<span><i className="lg-i fe-pi-start" aria-hidden /> 起始</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-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> <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 fe-pi-callout" aria-hidden /> 卡点</span>
<span><i className="lg-i lg-flow" aria-hidden /> 顺序流</span> <span><i className="lg-i lg-flow" aria-hidden /> 顺序流</span>
<span className="sep">|</span> <span className="sep">|</span>
@@ -1,9 +1,10 @@
/** /**
* 画布图 → 流程包:**合并**,不是重建。 * 画布图 → 流程包:**合并**,不是重建。
* *
* 这里守两条容易丢东西的路: * 这里守三条容易丢东西的路:
* ① 画布装不下的声明(边映射、驳回走向、负责人策略)保存后必须还在; * ① 画布装不下的声明(驳回走向、负责人策略)保存后必须还在;
* ② 回边以**卡点**呈现(MVP 的画法)—— 存盘要还原成语义边,不能连边一起丢。 * ② 回边以**卡点**呈现(MVP 的画法)—— 存盘要还原成语义边,不能连边一起丢;
* ③ 属性卡的工作副本(节点的链接实现 act、边的条件/映射)保存时要合并为语义。
*/ */
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts'; import { processPackageSchema } from '@contracts';
@@ -27,14 +28,22 @@ const basePkg: ProcessPackage = processPackageSchema.parse({
owner: { position: 'BD', strategy: 'rotation' }, owner: { position: 'BD', strategy: 'rotation' },
failure: { kind: 'terminate' }, 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: '终' }, { id: 'E', kind: 'end', label: '终' },
], ],
edges: [ edges: [
{ id: 'e-s-a', from: 'S', to: 'A', mapping: [{ target: 'x', source: { from: 'initiation', path: 'x' } }] }, { 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-a-b', from: 'A', to: 'B', 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-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: [], variables: [],
}); });
@@ -47,32 +56,32 @@ const graph: FlowGraph = {
nodes: [ nodes: [
{ id: 'S', kind: 'start', label: '起' }, { id: 'S', kind: 'start', label: '起' },
{ id: 'A', kind: 'activity', label: '甲', owner: 'BD' }, { 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: 'E', kind: 'end', label: '终' },
{ id: 'c1', kind: 'callout', label: '返回调整', to: 'A' }, { id: 'c1', kind: 'callout', label: '返回调整', to: 'A' },
], ],
edges: [ edges: [
{ from: 'S', to: 'A' }, { from: 'S', to: 'A' },
{ from: 'A', to: 'G' }, { from: 'A', to: 'B' },
{ from: 'G', to: 'E', label: '通过', tone: 'ok' }, { from: 'B', to: 'E', label: '通过', tone: 'ok' },
{ from: 'G', to: 'c1', label: '有异议', tone: 'bad' }, { from: 'B', to: 'c1', label: '有异议', tone: 'bad' },
], ],
}; };
describe('mergeGraphIntoPackage', () => { describe('mergeGraphIntoPackage', () => {
it('卡点还原成语义边 —— 回边不因「画成卡点」而消失', () => { it('卡点还原成语义边 —— 回边不因「画成卡点」而消失', () => {
const merged = mergeGraphIntoPackage(basePkg, graph); 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(); expect(back).toBeDefined();
// 旧边的 id 与 reopen 标记跟着保留:合并按 from→to 认亲 // 旧边的 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?.reopen).toBe(true);
expect(back?.condition).toBe('ok = false'); expect(back?.condition).toBe('ok = false');
}); });
it('卡点本身不入语义节点,但整张图存进 diagram', () => { it('卡点本身不入语义节点,但整张图存进 diagram', () => {
const merged = mergeGraphIntoPackage(basePkg, graph); 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 }[] }; const diagram = merged.diagram as { nodes: { id: string }[] };
expect(diagram.nodes.map((n) => n.id)).toContain('c1'); expect(diagram.nodes.map((n) => n.id)).toContain('c1');
}); });
@@ -87,7 +96,7 @@ describe('mergeGraphIntoPackage', () => {
const merged = mergeGraphIntoPackage(basePkg, withReject); 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 === '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('画布装不下的声明原样保留:边映射与负责人策略', () => { it('画布装不下的声明原样保留:边映射与负责人策略', () => {
@@ -96,4 +105,40 @@ describe('mergeGraphIntoPackage', () => {
const a = merged.nodes.find((n) => n.id === 'A'); const a = merged.nodes.find((n) => n.id === 'A');
expect(a?.kind === 'activity' && a.owner.strategy).toBe('rotation'); 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 { export interface NewNodeSpec {
kind: FlowNodeKind; kind: FlowNodeKind;
flavor?: "case" | "approval"; flavor?: "approval";
mode?: "x" | "and"; mode?: "x" | "and";
} }
@@ -430,12 +430,12 @@ const modelingModule = {
/* ---------- 元素盘(palette):拖放/点击落子 ---------- */ /* ---------- 元素盘(palette):拖放/点击落子 ---------- */
/* /*
* 网关不再入盘(产品决策 2026-07-28):分支 = 源节点的多条条件出边,汇合 = 多入边异或。 * 节点只分**活动点**与**审批点**(2026-07-28 第二轮):
* 画布仍**认得** gateway(渲染/序列化都留着)—— 历史版本要能看;只是不给新画。 * 编排面不选实现方式(标准/案例是活动库的事,链接哪个实现在属性卡决定);
* 网关不存在(分支 = 条件出边);起止与卡点是结构件与呈现件。
*/ */
export const PALETTE_SPECS: Array<{ key: string; label: string; className: string; spec: NewNodeSpec }> = [ 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.act", label: "活动点(蓝 · 链接活动实现后可运行)", 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.approval", label: "审批点(琥珀 · 人裁节点)", className: "fe-pi fe-pi-approval", spec: { kind: "activity", flavor: "approval" } }, { 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.start", label: "起始事件", className: "fe-pi fe-pi-start", spec: { kind: "start" } },
{ key: "create.end", label: "结束事件", className: "fe-pi fe-pi-end", spec: { kind: "end" } }, { 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.act", "后接活动点(自动连线)", "fe-ci fe-pi-sop", { kind: "activity" });
appendEntry("append.case", "后接案例活动(自动连线)", "fe-ci fe-pi-case", { kind: "activity", flavor: "case" });
appendEntry("append.approval", "后接审批点(自动连线)", "fe-ci fe-pi-approval", { kind: "activity", flavor: "approval" }); appendEntry("append.approval", "后接审批点(自动连线)", "fe-ci fe-pi-approval", { kind: "activity", flavor: "approval" });
// 网关已废除,不再可追加 —— 分支画成本节点的多条条件出边即可
appendEntry("append.end", "后接结束事件(自动连线)", "fe-ci fe-pi-end", { kind: "end" }); appendEntry("append.end", "后接结束事件(自动连线)", "fe-ci fe-pi-end", { kind: "end" });
entries["connect"] = { entries["connect"] = {
@@ -53,6 +53,8 @@ export interface FlowNode {
assign?: "auto" | "assist" | "human"; assign?: "auto" | "assist" | "human";
/** Wait/等待(BPMN 调用活动):被调流程规范名(软引用);画双边框+等待角词,点击跳被调流程。 */ /** Wait/等待(BPMN 调用活动):被调流程规范名(软引用);画双边框+等待角词,点击跳被调流程。 */
wait?: string; wait?: string;
/** 仅活动点:**链接的活动实现 id**(装载面)。属性卡选定,保存时合并为语义 activityId。 */
act?: string | undefined;
/** 手绘图(graph.manual)专用:节点左上角坐标(px,MANUAL_GRID 的倍数)。 */ /** 手绘图(graph.manual)专用:节点左上角坐标(px,MANUAL_GRID 的倍数)。 */
x?: number; x?: number;
y?: number; y?: number;
@@ -72,6 +74,10 @@ export interface FlowEdge {
to: string; to: string;
label?: string; label?: string;
tone?: FlowTone; tone?: FlowTone;
/** 边属性(传递契约)的工作副本:S-FEEL 条件。属性卡填写,保存时合并为语义边的 condition。 */
condition?: string | undefined;
/** 边属性(传递契约)的工作副本:字段映射(形状同语义边 mapping)。保存时合并。 */
mapping?: unknown[] | undefined;
/** 手绘图专用:整条折线路径(px,含两端;每段水平或垂直)。 */ /** 手绘图专用:整条折线路径(px,含两端;每段水平或垂直)。 */
points?: Array<{ x: number; y: number }>; points?: Array<{ x: number; y: number }>;
/** 手绘图专用:线标签文字中心点(注记,自由摆放)。 */ /** 手绘图专用:线标签文字中心点(注记,自由摆放)。 */
+7 -20
View File
@@ -32,12 +32,9 @@ export function mergeGraphIntoPackage(
priorEdge.set(`${from}→${to}`, e); priorEdge.set(`${from}→${to}`, e);
} }
// 画布节点里只有五要素入语义;卡点(callout)留在 diagram // 画布节点里只有活动点/审批点/起终点入语义;卡点(callout)留在 diagram
const semantic = graph.nodes.filter((n) => n.kind !== 'callout'); 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 nodes: ProcessNode[] = semantic.map((n) => {
const prior = renamed.get(n.id); const prior = renamed.get(n.id);
const id = nodeIdSchema.parse(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') { if (n.flavor === 'approval') {
const priorAp = prior?.kind === 'approval' ? prior : undefined; const priorAp = prior?.kind === 'approval' ? prior : undefined;
return { return {
@@ -95,7 +80,8 @@ export function mergeGraphIntoPackage(
return { return {
...base, ...base,
kind: 'activity' as const, 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: { owner: {
position: n.owner ?? priorAct?.owner.position ?? '未声明', position: n.owner ?? priorAct?.owner.position ?? '未声明',
strategy: priorAct?.owner.strategy ?? ('sole' as const), strategy: priorAct?.owner.strategy ?? ('sole' as const),
@@ -135,13 +121,14 @@ export function mergeGraphIntoPackage(
.filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to)) .filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to))
.map((e) => { .map((e) => {
const prior = priorEdge.get(`${e.from}→${e.to}`); const prior = priorEdge.get(`${e.from}→${e.to}`);
// 边属性(传递契约)以属性卡的工作副本优先;没动过的沿用旧声明
const condition = e.condition !== undefined ? (e.condition.trim() === '' ? undefined : e.condition) : prior?.condition;
return { return {
id: prior?.id ?? `e-${e.from}-${e.to}`, id: prior?.id ?? `e-${e.from}-${e.to}`,
from: nodeIdSchema.parse(e.from), from: nodeIdSchema.parse(e.from),
to: nodeIdSchema.parse(e.to), to: nodeIdSchema.parse(e.to),
// 装配映射与条件是「画布上看不见的声明」—— 原样保留,画布删不掉它们 mapping: (e.mapping as Edge['mapping'] | undefined) ?? prior?.mapping ?? [],
mapping: prior?.mapping ?? [], ...(condition === undefined ? {} : { condition }),
...(prior?.condition === undefined ? {} : { condition: prior.condition }),
reopen: prior?.reopen ?? false, reopen: prior?.reopen ?? false,
...(e.label === undefined ? {} : { label: e.label }), ...(e.label === undefined ? {} : { label: e.label }),
...(e.tone === 'ok' || e.tone === 'bad' ? { tone: e.tone } : {}), ...(e.tone === 'ok' || e.tone === 'bad' ? { tone: e.tone } : {}),
+29 -25
View File
@@ -20,12 +20,7 @@ export type InstanceMark = {
/** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */ /** 每行最多几个方框 —— 蛇形折行的行宽;取原型 business 预设的观感 */
const ROW_WIDTH = 4; const ROW_WIDTH = 4;
export function packageToGraph( export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark[] = []): FlowGraph {
pkg: ProcessPackage,
marks: readonly InstanceMark[] = [],
/** 活动范式(activityId → loop|flow):案例活动画绿 —— 范式住活动库,包里只有引用 */
paradigms?: ReadonlyMap<string, string>,
): FlowGraph {
const markOf = new Map(marks.map((m) => [m.nodeId, m])); 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 bottom = stored.nodes.reduce((max, n) => Math.max(max, (n.y ?? 0)), 0) + 90;
const extras: FlowNode[] = missing.map((n, i) => ({ const extras: FlowNode[] = missing.map((n, i) => ({
id: n.id, 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, 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 === '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, x: 30 + (i % 6) * 160,
y: bottom + Math.floor(i / 6) * 60, 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[]) => const withState = (list: readonly FlowNode[]) =>
list.map((n) => { list.map((n) => {
const state = stateOf(markOf.get(n.id)); 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 nodes: FlowNode[] = pkg.nodes.map((n) => {
const state = stateOf(markOf.get(n.id)); 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 === 'start') return { id: n.id, kind: 'start', label: n.label };
if (n.kind === 'end') return { id: n.id, kind: 'end', label: n.label }; if (n.kind === 'end') return { id: n.id, kind: 'end', label: n.label };
@@ -96,7 +100,7 @@ export function packageToGraph(
return { return {
...base, ...base,
owner: n.owner.position, owner: n.owner.position,
...(paradigms?.get(n.activityId) === 'loop' ? { flavor: 'case' as const } : {}), act: n.activityId,
...(n.in === undefined ? {} : { in: [...n.in] }), ...(n.in === undefined ? {} : { in: [...n.in] }),
...(n.out === undefined ? {} : { out: [...n.out] }), ...(n.out === undefined ? {} : { out: [...n.out] }),
}; };
+1 -1
View File
@@ -16,7 +16,7 @@
| M5-1 | `process` 未用 Mastra workflow 编译器,流转由自持确定性状态机 + `sys.node_instance` 承担 | 编码规范 §6.1 规定流程包编译为 Mastra 图;二者语义等价(挂起/恢复由节点实例表承担),先落可测的自持实现,Mastra 编译器作为可替换 adapter 后补 —— usecase 不动 | M5 收尾 | 进行中 | | M5-1 | `process` 未用 Mastra workflow 编译器,流转由自持确定性状态机 + `sys.node_instance` 承担 | 编码规范 §6.1 规定流程包编译为 Mastra 图;二者语义等价(挂起/恢复由节点实例表承担),先落可测的自持实现,Mastra 编译器作为可替换 adapter 后补 —— usecase 不动 | M5 收尾 | 进行中 |
| M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 | | M2-2 | pg-boss 已装未用 | 定时作业(监听时限扫描、周期触发器)随 M5 落地;当下派发器用 setInterval | M5 | 进行中 |
| M5-4 | AND 并行没有声明机制 —— 网关废除后(2026-07-28)多出边一律异或,画不出并行分叉 | 分支=条件出边、汇合=多入边异或已够 MVP;真要并行时以「并行组」声明加回(对应 Mastra `.parallel` 结构块),与 M5-1 编译器同批设计 | 需要并行的第一条流 | 进行中 | | M5-4 | AND 并行没有声明机制 —— 网关废除后(2026-07-28)多出边一律异或,画不出并行分叉 | 分支=条件出边、汇合=多入边异或已够 MVP;真要并行时以「并行组」声明加回(对应 Mastra `.parallel` 结构块),与 M5-1 编译器同批设计 | 需要并行的第一条流 | 进行中 |
| M5-5 | 含网关的历史版本**仅可读,不可激活**(激活报 `gateway-deprecated`);运行时留了三处网关兼容(`selectEdges` 单出边直通、`joinReady` 网关分支、`dispatchOf` 网关无调度) | 在途工作流按钉住版本读定义,老图还得走得动;等最后一条含网关的在途流走完、历史版本无人再看,兼容路径与 schema 的 `kind: 'gateway'` 一起摘除 | 含网关在途流清零后 | 进行中 | | M5-5 | ~~含网关历史版本的运行时兼容三处~~ | 用户令库只留一条流一个版本(外科手术清空),在途与历史归零 —— schema 的 `kind: 'gateway'`、`joinReady`、汇合等待、`gateway-deprecated` 违规项已于次轮全部摘除 | 2026-07-28 | 已清理 |
## 鉴权(ADR-010 落地后的已知欠账) ## 鉴权(ADR-010 落地后的已知欠账)
+5 -20
View File
@@ -107,15 +107,11 @@ export type NudgeLadder = z.infer<typeof nudgeLadderSchema>;
/* ── 节点 ─────────────────────────────────────────────────────────────── */ /* ── 节点 ─────────────────────────────────────────────────────────────── */
export const GATEWAY_KINDS = ['parallel', 'exclusive'] as const; /*
export const gatewayKindSchema = z.enum(GATEWAY_KINDS); * 节点只分**活动点**与**审批点**(产品决策 2026-07-28 第二轮),外加结构件起点/终点。
export type GatewayKind = z.infer<typeof gatewayKindSchema>; * 没有网关:分支 = 源节点的条件出边,汇合 = 多入边异或(schema 级保证,不是校验项)。
* 库中数据已随外科手术清空,历史读取无包袱;AND 并行待「并行组」声明(debt M5-4)。
export const GATEWAY_KIND_LABEL: Readonly<Record<GatewayKind, string>> = { */
parallel: '并行网关',
exclusive: '异或网关',
};
const nodeBase = { const nodeBase = {
id: nodeIdSchema, id: nodeIdSchema,
label: z.string().min(1), label: z.string().min(1),
@@ -175,15 +171,6 @@ export const processNodeSchema = z.discriminatedUnion('kind', [
nudge: nudgeLadderSchema.optional(), nudge: nudgeLadderSchema.optional(),
message: z.string().optional(), message: z.string().optional(),
}), }),
z.object({
...nodeBase,
kind: z.literal('gateway'),
gateway: gatewayKindSchema,
/** 分叉 / 汇合 */
mode: z.enum(['fork', 'join']),
/** 判据说明(人读):条件本体仍在出边的 S-FEEL 上,这里是「为什么这么分」 */
basis: z.string().optional(),
}),
]); ]);
export type ProcessNode = z.infer<typeof processNodeSchema>; export type ProcessNode = z.infer<typeof processNodeSchema>;
@@ -242,8 +229,6 @@ export const CLOSURE_VIOLATION_KINDS = [
'dangling-node', 'dangling-node',
/** 多出边的无条件默认兜底超过一条 —— 第二条默认永远走不到 */ /** 多出边的无条件默认兜底超过一条 —— 第二条默认永远走不到 */
'multi-default-edge', 'multi-default-edge',
/** 网关已废除:历史版本仅可读,不可激活(2026-07-28) */
'gateway-deprecated',
'tolerate-required-dependency', 'tolerate-required-dependency',
'unknown-activity', 'unknown-activity',
'missing-owner-position', 'missing-owner-position',