diff --git a/api-report/apps/api/src/modules/process/index.d.ts b/api-report/apps/api/src/modules/process/index.d.ts index 0c0fb53..55bbaa7 100644 --- a/api-report/apps/api/src/modules/process/index.d.ts +++ b/api-report/apps/api/src/modules/process/index.d.ts @@ -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 { ProcessServiceDeps } 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 { aggregateWorkflowState, FAILURE_DIGESTIONS, isDigested, nextInstanceNumber, resolveOwner, } from './internal/domain/owner-resolution.ts'; export { createProcessStore } from './internal/adapter/process-store.ts'; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 7776017..620c9a6 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -587,26 +587,6 @@ export declare const listProcessPackagesRoute: { x: z.ZodOptional; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray>>; }, z.core.$strip>; export type NudgeLadder = z.infer; -export declare const GATEWAY_KINDS: readonly ["parallel", "exclusive"]; -export declare const gatewayKindSchema: z.ZodEnum<{ - parallel: "parallel"; - exclusive: "exclusive"; -}>; -export type GatewayKind = z.infer; -export declare const GATEWAY_KIND_LABEL: Readonly>; export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"start">; id: z.core.$ZodBranded; @@ -253,26 +246,6 @@ export declare const processNodeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ x: z.ZodOptional; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">; export type ProcessNode = z.infer; export declare const callInterfaceSchema: z.ZodObject<{ @@ -427,26 +400,6 @@ export declare const processPackageSchema: z.ZodObject<{ x: z.ZodOptional; y: z.ZodOptional; }, 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; - id: z.core.$ZodBranded; - label: z.ZodString; - sub: z.ZodOptional; - layout: z.ZodOptional; - y: z.ZodOptional; - }, z.core.$strip>>; }, z.core.$strip>], "kind">>; edges: z.ZodArray>; }, z.core.$strip>; export type ProcessPackage = z.infer; -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<{ "edge-unmapped": "edge-unmapped"; "required-unmapped": "required-unmapped"; "type-conflict": "type-conflict"; "dangling-node": "dangling-node"; "multi-default-edge": "multi-default-edge"; - "gateway-deprecated": "gateway-deprecated"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; "missing-owner-position": "missing-owner-position"; @@ -508,7 +460,6 @@ export declare const closureViolationSchema: z.ZodObject<{ "type-conflict": "type-conflict"; "dangling-node": "dangling-node"; "multi-default-edge": "multi-default-edge"; - "gateway-deprecated": "gateway-deprecated"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; "missing-owner-position": "missing-owner-position"; @@ -528,7 +479,6 @@ export declare const closureReportSchema: z.ZodObject<{ "type-conflict": "type-conflict"; "dangling-node": "dangling-node"; "multi-default-edge": "multi-default-edge"; - "gateway-deprecated": "gateway-deprecated"; "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; "missing-owner-position": "missing-owner-position"; diff --git a/apps/api/src/app/__tests__/seed-mvp-flow.test.ts b/apps/api/src/app/__tests__/seed-mvp-flow.test.ts index a4b654b..abcfcb3 100644 --- a/apps/api/src/app/__tests__/seed-mvp-flow.test.ts +++ b/apps/api/src/app/__tests__/seed-mvp-flow.test.ts @@ -6,10 +6,9 @@ import { describe, expect, it } from 'vitest'; import { MVP_PACKAGE } from '../seed-mvp-flow.ts'; describe('MVP_PACKAGE', () => { - it('10 语义节点 · 14 边 · 零网关 · 一审批点双终点', () => { + it('10 语义节点 · 14 边 · 一审批点双终点(网关在 schema 里已不存在)', () => { expect(MVP_PACKAGE.nodes).toHaveLength(10); 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 === 'end')).toHaveLength(2); }); diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index b7f5f83..964c009 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -246,11 +246,11 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ nodes: [ { 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: 'A2', kind: 'activity', label: '替代选型', code: 'A2', owner: 'FAE', flavor: 'case', x: 290, y: 60 }, - { id: 'A3', kind: 'activity', label: '寻源询价', code: 'A3', owner: 'PRM', flavor: 'case', x: 470, y: 60 }, + { id: 'A2', kind: 'activity', label: '替代选型', code: 'A2', owner: 'FAE', x: 290, 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: '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: '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 }, diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index a7aa40c..a850a5b 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -1,6 +1,6 @@ # module: process -职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图即校验 · 不闭合不得保存)、 +职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图不检查 · 不闭合不得激活)、 负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开 不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、 画布的绘制(见 apps/web 的 gov-process) @@ -14,7 +14,7 @@ - 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停) - 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState` (`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011) - `isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady` + `isDigested` `nextInstanceNumber` `selectEdges` `assemble` `rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder` (`advanceOrder` = 推进次序,按边拓扑排、排除回边、并行支按编号定序; 呈现用,不参与流转判断 —— 图纸坐标不配决定次序) @@ -28,13 +28,20 @@ - **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验, 随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时 跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。 - 违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、含网关、 - 容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。 - - **无网关**(产品决策 2026-07-28):分支 = 源节点的多条条件出边(恰取一条, - 无条件边作默认兜底、至多一条),汇合 = 多入边异或(上游互斥,只会来一条)。 - schema 仍收 `kind: 'gateway'` 只为读历史版本 —— 在途工作流照走(运行时兼容), - 但含网关的定义激活即 `gateway-deprecated` 违规,**仅可读,不可激活**。 + 违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、 + 活动未装载、岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。 + - **纯编排模型**(产品决策 2026-07-28 第二轮):流程包只决定**活动编排**与 + **活动传递的契约**,不决定活动的实现方式(范式/版本住活动库,节点仅以 + `activityId` 链接)。节点只分**活动点**与**审批点**(起点/终点是结构件); + `kind: 'gateway'` 已从 schema 摘除(库经外科手术清空,无历史包袱)。 + 分支 = 源节点的多条条件出边(恰取一条,无条件边作默认兜底、至多一条), + 汇合 = 多入边异或(上游互斥,只会来一条 —— `joinReady` 已删,没有「等回齐」)。 AND 并行暂无声明机制,将来以「并行组」加回(对应 Mastra `.parallel`,见 debt.md)。 + - **激活双判据**:①图编排闭合完善(结构 + 属性 + 传递契约,`checkClosure` 的 + 非 unknown-activity 项)②活动装载(节点链接的实现已安装,`unknown-activity` 项)。 + 两面都清零才可激活;图照画照存,未装载不拦保存。 + 系统隐性上下文(审批 `verdict`、放行选口 `outcome` 的判据注入、起单载荷穿透) + 由引擎决定,不进设计者的编排面。 - **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占 (同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。 起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。 @@ -55,8 +62,8 @@ 一条都没中而该节点确有出边 = **死路**,发 `route-dead-end` 事件 (哪个节点、各出边的条件、判据里当时有哪些键 —— 只记键名不记值)。 审批点的裁决(`verdict`)由引擎注入判据,两个去向就是它自己的两条条件出边 - - **异或汇合不等**(`joinReady`):互斥分支结构上只会来一条,等齐 = 等一个不可能 - 到来的分支,流会无声卡死;AND 回齐逻辑仅剩历史版本的并行网关在用,容忍分支以「已消化」计 + - **异或汇合不等**:互斥分支结构上只会来一条,等齐 = 等一个不可能到来的分支, + 流会无声卡死 —— 汇合等待机制已随网关整体摘除,容忍分支消化后直接推进 - **流程层不自动重试活动**(过程内重试归 Harness); 流级重做一律人裁决后**重开新任务实例** - **驳回走向声明式**:审批点的驳回按 `reject` 声明回指定节点重开新实例,不由临场决定 diff --git a/apps/api/src/modules/process/__tests__/advance-order.test.ts b/apps/api/src/modules/process/__tests__/advance-order.test.ts index d5ac520..840ba5c 100644 --- a/apps/api/src/modules/process/__tests__/advance-order.test.ts +++ b/apps/api/src/modules/process/__tests__/advance-order.test.ts @@ -4,6 +4,7 @@ * 这条曾经拿 `layout.col` 排,在**回形走线**的图上读错过:MVP 图第 0 行左→右、 * 第 1 行右→左、第 2 行折回最左,A7 的列位跟 A1 并列,链就读成 A1 → A7 → A2 → A6 → A3。 * 所以这里的第一个用例就是那张回形图 —— 保证它只能读作 A1 → … → A7。 + * (图形状 = 现行无网关模型:分支是条件出边,条件对拓扑序无影响,这里一律省略。) */ import { describe, expect, it } from 'vitest'; import type { Edge, ProcessNode, ProcessPackage } from '@contracts'; @@ -21,16 +22,6 @@ const act = (id: string, col: number, row: number): ProcessNode => layout: { row, col }, }) 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 => ({ id: `e-${from}-${to}`, from, to, mapping: [], reopen }) as never; @@ -54,40 +45,34 @@ const acts = (p: ProcessPackage): string[] => .map((n) => n.id); 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( [ { id: 'start', kind: 'start', label: '询价', layout: { row: 0, col: 0 } } as never, act('A1', 1, 0), - gw('G1', 'fork', 2, 0), - act('A2', 3, 0), - gw('G2', 'fork', 4, 0), - act('A3', 5, 0), - gw('G2J', 'join', 6, 0), - act('A4', 7, 0), - act('A5', 6, 1), - act('A6', 3, 1), - gw('G4', 'fork', 2, 1), + act('A2', 2, 0), + act('A3', 3, 0), + act('A4', 4, 1), + act('A5', 3, 1), + act('A6', 2, 1), act('A7', 1, 2), { 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, ], [ edge('start', 'A1'), - edge('A1', 'G1'), - edge('G1', 'A2'), - edge('G1', 'G2'), - edge('A2', 'G2'), - edge('G2', 'A3'), - edge('G2', 'G2J'), - edge('A3', 'G2J'), - edge('G2J', 'A4'), + edge('A1', 'A2'), + edge('A1', 'A3'), + edge('A1', 'A4'), + edge('A2', 'A3'), + edge('A2', 'A4'), + edge('A3', 'A4'), edge('A4', 'A5'), edge('A5', 'A6'), - edge('A6', 'G4'), - edge('G4', 'end_won'), - edge('G4', 'A4', true), // 回边:有异议退回重新定价 - edge('G4', 'A7'), + edge('A5', 'A4', true), // 回边:驳回退回重新定价 + edge('A6', 'end_won'), + edge('A6', 'A4', true), // 回边:有异议退回重新定价 + edge('A6', 'A7'), edge('A7', 'end_arch'), ], ); @@ -96,12 +81,10 @@ describe('advanceOrder', () => { expect(acts(serpentine)).toEqual(['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7']); }); - it('起点在最前、网关按边落位、两个终点都在 A7 之后', () => { + it('起点在最前,两个终点都在末段活动之后', () => { const order = ids(serpentine); 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_arch')).toBeGreaterThan(order.indexOf('A7')); }); @@ -125,27 +108,26 @@ describe('advanceOrder', () => { expect(acts(pkg(nodes, [...forward, edge('AB', 'AZ')]))).toEqual(['AB', 'AZ']); }); - it('真并行支按编号定序 —— 同一个包在谁手上都读作同一条链', () => { - const parallel = pkg( + it('互斥分支在图上本无先后 —— 链按编号定序,同一个包在谁手上都读作同一条链', () => { + const branchy = pkg( [ { 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('AB', 2, 1), - gw('J', 'join', 3, 0), - { id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 4 } } as never, + { id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 3 } } as never, ], [ - edge('start', 'F'), - edge('F', 'AZ'), - edge('F', 'AB'), - edge('AZ', 'J'), - edge('AB', 'J'), - edge('J', 'end'), + // A0 的两条互斥出边(条件对拓扑序无影响,省略)—— 书写顺序 AZ 在前 + edge('start', 'A0'), + edge('A0', 'AZ'), + edge('A0', 'AB'), + edge('AZ', 'end'), + edge('AB', 'end'), ], ); // 书写顺序是 AZ 在前,但编号 AB < AZ —— 链按编号读 - expect(acts(parallel)).toEqual(['AB', 'AZ']); + expect(acts(branchy)).toEqual(['A0', 'AB', 'AZ']); }); it('不可达节点不丢 —— 按编号缀在末尾', () => { diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index 101187c..f543678 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -1,8 +1,6 @@ /** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */ import { describe, expect, it } from 'vitest'; -import { processPackageSchema } from '@contracts'; import type { ProcessPackage } from '@contracts'; -import { joinReady } from '../internal/domain/routing.ts'; import { checkClosure } from '../internal/domain/closure.ts'; import type { ActivityContractFace } from '../internal/domain/closure.ts'; import { @@ -126,25 +124,6 @@ describe('checkClosure —— 不闭合不得激活', () => { 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', () => { 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); - }); -}); - /* - * 异或汇合不等 —— 这条曾经让流无声停住。 - * - * G2J(寻源汇合)是异或:「需要寻源」走 A3、「不需要」直通,两条互斥、只来一条。 - * 而 `joinReady` 原本按并行汇合写(全部责任分支收敛才放行),于是在「不需要寻源」那条路上 - * 永远在等 A3 —— 一个结构上不会激活的分支。症状最坑的地方是**连死路事件都没有**: - * 边是选中了的,只是在汇合判定里被 continue 跳过,从外面看就是 A1 办结后什么都不再发生。 + * joinReady 已随网关一并摘除(2026-07-28 第二轮):多入边即异或汇合 —— 上游互斥, + * 只会来一条,**没有「等回齐」这回事**;AND 并行待「并行组」声明时再带自己的汇合语义。 + * 从前它守的坑(异或汇合等一个不可能到来的分支、流无声停住)从机制上不存在了。 */ -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 的「还差一条 → 不回齐」守着,不在这儿重复 -}); diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index e68f9ad..85acd79 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -40,7 +40,6 @@ export { advanceOrder, assemble, dispatchOf, - joinReady, rejectRouteOf, selectEdges, startNodeOf, diff --git a/apps/api/src/modules/process/internal/domain/closure.ts b/apps/api/src/modules/process/internal/domain/closure.ts index e5efd4a..d6904ac 100644 --- a/apps/api/src/modules/process/internal/domain/closure.ts +++ b/apps/api/src/modules/process/internal/domain/closure.ts @@ -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}`, }); } - if (node.kind === 'gateway') { - violations.push(...checkGateway(node, incoming.get(node.id) ?? [], outgoing.get(node.id) ?? [])); - } - /* * 多出边 = 异或分支(恰取一条):条件命中优先,无条件边作默认兜底。 * 默认兜底**至多一条** —— 两条无条件出边就是「都想当默认」,运行时按声明序 @@ -169,28 +163,6 @@ function checkActivityNode( return out; } -function checkGateway( - node: Extract, - 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 失败走向)。 */ diff --git a/apps/api/src/modules/process/internal/domain/routing.ts b/apps/api/src/modules/process/internal/domain/routing.ts index 3898f7f..14973c9 100644 --- a/apps/api/src/modules/process/internal/domain/routing.ts +++ b/apps/api/src/modules/process/internal/domain/routing.ts @@ -2,7 +2,7 @@ * 流程引擎的确定性流转 —— 纯逻辑,零 IO、**零 LLM**(编码规范 §5.3;语义出处 OS 04)。 * * 「流程引擎 —— 确定性流转:接收调用接口的起单、按结构调度节点、把审批点卡到人的工位、 - * 执行网关的分合、把每次流转记入事件。**零 LLM、零临场**。」 + * 按条件出边选路、把每次流转记入事件。**零 LLM、零临场**。」 */ import type { Edge, ProcessNode, ProcessPackage } from '@contracts'; @@ -72,66 +72,20 @@ export function selectEdges( scope: Readonly>, evaluate: PredicateEvaluator, ): Edge[] { - const node = pkg.nodes.find((n) => n.id === fromNodeId); const outgoing = pkg.edges.filter((e) => e.from === fromNodeId); /* - * **多出边一律异或,不分节点种类**(产品决策 2026-07-28:网关去节点化)。 - * - * 分支不再是网关节点的专利 —— 活动/审批点自己的多条条件出边就是分支, - * 判据读的是它自己的出参(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。 - * 语义与从前的异或网关逐字相同:条件命中优先(按声明序),无条件边作默认兜底, - * 恰取一条。并行(AND)没有节点了也没有声明机制 —— 要并行时以「并行组」声明加回 - * (对应 Mastra 的 `.parallel` 结构块),见债务簿。 - * - * 遗留的网关节点(历史版本里还有)走同一条逻辑 —— 异或网关的语义本来就是这个; - * 并行网关分叉的「全发」不再支持,历史包若有,激活时已被闭合校验拦下。 + * **多出边一律异或,不分节点种类**(与 Mastra 的 `.branch` 同构:条件挂在步与步之间)。 + * 条件命中优先(按声明序),无条件边作默认兜底,恰取一条。 + * 并行(AND)没有声明机制 —— 要并行时以「并行组」加回(对应 `.parallel`,债 M5-4)。 */ - if (outgoing.length <= 1) { - // 并行网关的遗留直通不受损:单出边不判条件,原样走 - void node; - return outgoing; - } + if (outgoing.length <= 1) return outgoing; const hit = outgoing.find((e) => e.condition !== undefined && evaluate(e.condition, scope)); const fallback = outgoing.find((e) => e.condition === undefined); const chosen = hit ?? fallback; return chosen === undefined ? [] : [chosen]; } -/** - * AND 汇合是否回齐:全部**责任分支**的来源都已收敛。 - * - * 网关本身不是责任单元、永远不会有实例,所以来源是网关的入边(分叉直连汇合的**直通道**) - * 不参与等待 —— 分叉一发生它就算过了。若把它也算进去,汇合会永远等一个不可能到来的 - * 「网关收敛」,整条流卡死在这里。 - */ -export function joinReady( - pkg: ProcessPackage, - joinNodeId: string, - converged: ReadonlySet, - tolerated: ReadonlySet, -): 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): Dispatch | undefined { if (node.kind === 'activity') return { kind: 'start-task', node, payload }; diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index 9551083..8d10637 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -23,7 +23,7 @@ import type { import { checkClosure } from '../domain/closure.ts'; import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } 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 = { readonly store: ProcessStore; @@ -229,23 +229,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { const target = pkg.nodes.find((n) => n.id === edge.to); 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 { await enter(record, pkg, target, arriving); } catch (error) { @@ -450,8 +440,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { if (disposition.kind === 'tolerate') { /* * 容忍 = 这条支线的失败被吸收,**流继续往下走**。 - * 只把实例标成「已消化」而不往下推,汇合就永远等不到它: - * `joinReady` 认 tolerated,可没人再去问一次它够不够齐。 + * 光把实例标成「已消化」而不往下推,这条线就断在这儿 —— 下游谁也不会再被叫起。 * 载荷为空 —— 容忍分支本就没有产出,下游的必填映射不得依赖它(闭合校验已拦)。 */ await advanceFrom(record, pkg, nodeId, {}); diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 8173115..fc39907 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -562,6 +562,11 @@ .g-ex { cursor: pointer; border-left-color: #b0413e; } .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-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); } /* 异常板高度随内容走,不被右侧属性卡拉高 —— 三条异常配 900px 空板才是脏 */ diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index e5aeff8..c50f1fc 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -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 type { ClosureReport, ProcessPackage, ProcessNode } 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 { packageToGraph } from '@web-shared/gov/flow/pkg-to-graph.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 { 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 | 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 & { 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 ( +
+ + + onPatch((x) => ({ ...x, condition: e.target.value }))} + /> + + + + onPatch((x) => { + const { label: _drop, ...rest } = x; + return e.target.value === '' ? rest : { ...rest, label: e.target.value }; + }) + } + /> + + + + {rows.map((r, i) => { + const known = SOURCE_KINDS.some((k) => k.k === r.source.from); + return ( +
+ patchRow(i, (x) => ({ ...x, target: e.target.value }))} + /> + ← + {known ? ( + + ) : ( + // workflow-var / expression 等是系统侧来源 —— 保留展示,不给编辑入口 + {String(r.source.from)}(系统) + )} + {r.source.from === 'output-port' && ( + <> + patchRow(i, (x) => ({ ...x, source: { ...x.source, port: e.target.value } }))} + /> + patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))} + /> + + )} + {r.source.from === 'initiation' && ( + patchRow(i, (x) => ({ ...x, source: { ...x.source, path: e.target.value } }))} + /> + )} + {r.source.from === 'constant' && ( + patchRow(i, (x) => ({ ...x, source: { ...x.source, value: e.target.value } }))} + /> + )} + +
+ ); + })} + setRows([...rows, { target: '', source: { from: 'output-port', port: '', path: '' } }]), + }, + ]} + /> +
+
+ ); +} + /* ---------------- 台面:总览 · 流程包 · 触发器 ---------------- */ export function WorkflowsHome({ tab, nav }: HomeProps) { @@ -218,7 +363,6 @@ function FlowShelf({ nav }: { nav: Nav }) { const c = { activity: shown.nodes.filter((n) => n.kind === 'activity').length, approval: shown.nodes.filter((n) => n.kind === 'approval').length, - gateway: shown.nodes.filter((n) => n.kind === 'gateway').length, }; return ( ))} - {node === null ? ( - 点画布上的节点(或上面的节点条)选一个 —— 属性就在下面填,网关判据与卡点去向同样在此改。 - ) : ( + {/* 边拣选条:边属性 = 传递契约(条件 + 映射)—— 图只定顺序,契约在这儿填 */} +
+ {(graph?.edges ?? []).map((e, i) => { + const toSem = calloutToOf(graph)?.get(e.to) ?? e.to; + const brief = e.condition !== undefined && e.condition !== '' ? ` [${e.condition}]` : ''; + return ( + + ); + })} +
+ {node === null && selEdge === null ? ( + + 点画布或上面的拣选条选一件 —— 节点填岗位与链接实现,边填传递契约(条件 + 映射)。 + 审批裁决(verdict)与放行选口(outcome)由系统注入判据,不用你声明。 + + ) : node === null && selEdge !== null && graph !== null ? ( + { + setGraph((g) => + g === null ? g : { ...g, edges: g.edges.map((e, i) => (i === selEdge ? f(e) : e)) }, + ); + setDirty(true); + }} + /> + ) : node === null ? null : ( <>
@@ -841,34 +1023,21 @@ function FlowLab({ } > {node.kind === 'activity' && ( - + - patch((n) => ({ ...n, flavor: k === 'flow' ? undefined : (k as 'case' | 'approval') })) + patch((n) => ({ ...n, flavor: k === 'approval' ? ('approval' as const) : undefined })) } /> )} - {node.kind === 'gateway' && ( - - patch((n) => ({ ...n, mode: k as 'x' | 'and' }))} - /> - - )}