From 03eb666ac2555222b24017acefc16da7e1a6cedb Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Wed, 29 Jul 2026 14:25:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E9=9D=A2=E6=8B=86=E5=88=86=E2=91=AE:?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20Mastra=20=E6=95=B0=E6=8D=AE=E6=B5=81=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E8=BE=B9=E5=8F=AA=E8=A1=A8=E8=BE=BE?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F,=E6=95=B0=E6=8D=AE=E6=8C=89=E8=BE=93?= =?UTF-8?q?=E5=85=A5/=E8=BE=93=E5=87=BA=E5=AD=97=E6=AE=B5=E5=90=8D?= =?UTF-8?q?=E6=B5=81=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户定的机制对齐:Mastra 的边不传数据,所有约束力靠输入/输出字段(schema 咬合)完成。 照此把「边映射」整个退役: · 契约:edge 去 mapping,审批 reject 去 mapping —— 边只剩 顺序 + 条件(读上游 输出字段)+ 标签/回边。fieldMapping 只留给触发器的起单装配(系统边界)。 存量包无需迁移:zod 解析时把旧 jsonb 里的 mapping 静默剥掉,图照常有效。 · 运行时:assemble(边装配)→ flatScope(铺平作用域)—— 起单入参垫底 ← 沿途各输出口字段依次盖上 ← 刚发射的载荷(Mastra 语义: 下一步的 input 首先是上一步的 output)。下游按自己的契约取字段; 「__」前缀是运行时自留地,不入业务作用域。审批驳回重开、失败改道同一条路。 · 闭合校验:边未映射/必填未映射/容忍依赖三条映射类检查退役 (宽进之下必填有没有着落是运行时补全单的事,不拦激活); 激活闸只留一条硬伤 —— **同名不同型**:名字即管道,名同型异必坏, 容忍依赖改按名判。改名映射不存在:名不对改契约,不是加映射。 · 种子:A2 的入参 material 更名 pn(它就是 PN;上游 A1 发的名就得是它收的名), 契约 0.3.0、槽位 0.8.0;流程种子边全部去映射。 · 前端:边属性卡砍掉「映射 · 载荷装配」整节,只剩 E号/方向/条件/标签; 节点属性卡新增「数据接口 · 按字段名流动」——每个输入字段标出运行时从哪来 (起单入参 / 上游 X / 补全单),输出口列出发什么名;沿边回溯推导,设计期看得见。 画布保存与依赖文件导出同步去映射。 E2E 顺带把宽进接进真流:快速归档流的 A3 要 outcome 而流里没人产 —— 测试如实走补全路(等人 → 答卡 outcome=won → 开跑 → completed)。 实测走查:A2 节点卡上 pn←上游A1、rfqRef←起单入参、scenario←补全单,推导全对。 五闸绿;契约快照随本提交入库。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../apps/api/src/modules/process/index.d.ts | 2 +- .../contracts/src/api/governance.d.ts | 244 ------------------ .../contracts/src/process-package.d.ts | 93 +------ .../app/__tests__/new-package-flow.it.test.ts | 20 +- .../src/app/__tests__/seed-mvp-flow.test.ts | 2 +- apps/api/src/app/seed-mvp-flow.ts | 58 +---- apps/api/src/app/seed-mvp-slots.ts | 15 +- apps/api/src/modules/process/CONTRACT.md | 8 +- .../modules/process/__tests__/closure.test.ts | 70 ++--- apps/api/src/modules/process/index.ts | 2 +- .../internal/adapter/mastra-runtime.ts | 6 +- .../process/internal/domain/closure.ts | 70 +++-- .../process/internal/domain/routing.ts | 60 ++--- .../internal/runtime/self-hosted-runtime.ts | 11 +- apps/web/src/app/styles/console.css | 13 + .../features/gov-process/internal/panel.tsx | 234 ++++++++--------- .../gov/flow/__tests__/graph-to-pkg.test.ts | 18 +- apps/web/src/shared/gov/flow/graph-to-pkg.ts | 3 +- apps/web/src/shared/gov/flow/pkg-to-graph.ts | 1 - apps/web/src/shared/gov/gov-route.ts | 2 +- docs/产品设计.md | 2 +- packages/contracts/src/process-package.ts | 19 +- 22 files changed, 264 insertions(+), 689 deletions(-) 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 3c1ae84..6098dda 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, PinnedVersionError, Positi export type { FlowRuntime, 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, rejectRouteOf, selectEdges, startNodeOf, } from './internal/domain/routing.ts'; +export { advanceOrder, flatScope, 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 15c4fe9..909bbdc 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -524,26 +524,6 @@ export declare const listProcessPackagesRoute: { reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -573,26 +553,6 @@ export declare const listProcessPackagesRoute: { from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -742,26 +682,6 @@ export declare const saveProcessRoute: { from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -895,26 +795,6 @@ export declare const saveProcessRoute: { from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -1085,26 +943,6 @@ export declare const setProcessStateRoute: { from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -1242,26 +1060,6 @@ export declare const validateProcessRoute: { from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -2412,26 +2188,6 @@ export declare const govWorkflowRoute: { from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional; export type OwnerSpec = z.infer; /** - * 映射来源四类:上游输出口载荷字段 / 流程变量 / 起单载荷 / 常量; - * 允许 S-FEEL 表达式做转换(与触发器字段映射同一形式)。 + * 字段映射 —— **只用于触发器的起单装配**(外界事件字段 → 调用接口字段,系统边界)。 + * 流程内部**不用它**(2026-07-30,对齐 Mastra):边只表达顺序, + * 数据靠输入/输出字段名流动,约束力在两端活动契约的 schema 上。 */ export declare const mappingSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ from: z.ZodLiteral<"output-port">; @@ -76,26 +77,6 @@ export declare const edgeSchema: z.ZodObject<{ from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -317,26 +278,6 @@ export declare const processPackageSchema: z.ZodObject<{ reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ kind: z.ZodLiteral<"reopen">; nodeId: z.core.$ZodBranded; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; }, z.core.$strip>, z.ZodObject<{ kind: z.ZodLiteral<"terminate">; }, z.core.$strip>], "kind">; @@ -366,26 +307,6 @@ export declare const processPackageSchema: z.ZodObject<{ from: z.core.$ZodBranded; to: z.core.$ZodBranded; condition: z.ZodOptional; - mapping: z.ZodDefault; - port: z.ZodString; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"workflow-var">; - name: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"initiation">; - path: z.ZodString; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"constant">; - value: z.ZodUnknown; - }, z.core.$strip>, z.ZodObject<{ - from: z.ZodLiteral<"expression">; - sfeel: z.ZodString; - }, z.core.$strip>], "from">; - }, z.core.$strip>>>; label: z.ZodOptional; tone: z.ZodOptional>; }, 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", "tolerate-required-dependency", "unknown-activity"]; +export declare const CLOSURE_VIOLATION_KINDS: readonly ["type-conflict", "dangling-node", "multi-default-edge", "tolerate-required-dependency", "unknown-activity"]; 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"; @@ -414,8 +333,6 @@ export declare const closureViolationKindSchema: z.ZodEnum<{ export type ClosureViolationKind = z.infer; export declare const closureViolationSchema: z.ZodObject<{ kind: z.ZodEnum<{ - "edge-unmapped": "edge-unmapped"; - "required-unmapped": "required-unmapped"; "type-conflict": "type-conflict"; "dangling-node": "dangling-node"; "multi-default-edge": "multi-default-edge"; @@ -432,8 +349,6 @@ export declare const closureReportSchema: z.ZodObject<{ closed: z.ZodBoolean; violations: z.ZodArray i.nodeId === 'A3'); + expect(a3Task?.taskId, 'A3 已进入(等补全,不是没起)').toBeDefined(); + const held = await container.task.get(a3Task!.taskId as never); + expect(held.runtime, '缺 outcome —— 等人补全').toBe('awaiting-human'); + const cards = await container.humanTool.chatCards(a3Task!.taskId as never); + const ask = cards.find((c) => c.k === 'elicitation' && c.done === undefined); + expect(ask, '补全卡在对话流里').toBeDefined(); + await container.humanTool.replyChatCard( + { k: 'elicitation', cardId: ask!.id as never, values: { outcome: 'won' } }, + chaihao as never, + ); + await pump(); + + // A3 归档:补全齐了,照旧一张收口验收 await acceptCurrent(record.id); expect(await seq(record.id)).toEqual([ 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 88721aa..a5e71c9 100644 --- a/apps/api/src/app/__tests__/seed-mvp-flow.test.ts +++ b/apps/api/src/app/__tests__/seed-mvp-flow.test.ts @@ -40,7 +40,7 @@ describe('MVP_PACKAGE', () => { const rejectEdge = outs.find((e) => e.to === 'A4')!; expect(rejectEdge.reopen).toBe(true); expect(rejectEdge.to).toBe(a5.reject.nodeId); - expect(rejectEdge.mapping).toEqual(a5.reject.mapping); + // 边不带映射(对齐 Mastra):重开载荷 = 铺平作用域,A4 按自己的契约取字段 }); /* diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index 48f6842..3e1d623 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -24,11 +24,6 @@ const activityNode = (id: string, label: string, activityId: string, col: number /** 受理口的两枚去向旗与记录 —— A4 重新入场(回边 / 驳回)时照此装配 */ -const REENTER_A4 = [ - { target: 'records', source: { from: 'output-port' as const, port: 'intake', path: 'records' } }, - { target: 'customerCode', source: { from: 'initiation' as const, path: 'customerCode' } }, -]; - export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ id: 'unified-quote', name: '统一报价', @@ -69,7 +64,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ * 引擎先看图(有边走边,没边才退回本声明),两处须字字对齐 —— 有测试守着。 * 本声明不能删:schema 必填,且它是通用机制(不是每条流都把驳回画成边)。 */ - reject: { kind: 'reopen', nodeId: 'A4', mapping: REENTER_A4 }, + reject: { kind: 'reopen', nodeId: 'A4' }, nudge: { steps: [{ after: 'PT20M', action: 'nudge' }], deadline: 'PT30M' }, layout: { row: 1, col: 5, x: 290, y: 310 }, }, @@ -83,13 +78,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ id: 'E1', from: 'start', to: 'A1', - mapping: [ - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - { target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } }, - { target: 'subject', source: { from: 'initiation', path: 'subject' } }, - { target: 'body', source: { from: 'initiation', path: 'body' } }, - { target: 'quotedAt', source: { from: 'initiation', path: 'quotedAt' } }, - ], reopen: false, }, @@ -100,11 +88,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ to: 'A2', condition: 'needAlternative = true', label: '需要选型', - mapping: [ - { target: 'material', source: { from: 'output-port', port: 'intake', path: 'pn' } }, - { target: 'needSourcing', source: { from: 'output-port', port: 'intake', path: 'needSourcing' } }, - { target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } }, - ], reopen: false, }, { @@ -113,11 +96,10 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ to: 'A3', condition: 'needAlternative = false and needSourcing = true', label: '直接寻源', - mapping: [{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }], reopen: false, }, // 默认兜底:不选型不寻源 → 直接定价(条件全不中时走它,旗齐时它就是第三种情形) - { id: 'E4', from: 'A1', to: 'A4', label: '直接定价', mapping: REENTER_A4, reopen: false }, + { id: 'E4', from: 'A1', to: 'A4', label: '直接定价', reopen: false }, /* A2 的两条出边 —— 原 G2 在选型路径上的那一问 */ { @@ -126,24 +108,18 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ to: 'A3', condition: 'needSourcing = true', label: '仍需寻源', - mapping: [{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } }], reopen: false, }, - { id: 'E6', from: 'A2', to: 'A4', label: '选型后定价', mapping: REENTER_A4, reopen: false }, + { id: 'E6', from: 'A2', to: 'A4', label: '选型后定价', reopen: false }, { id: 'E7', from: 'A3', to: 'A4', label: '供应商底价表', - mapping: [ - ...REENTER_A4, - // 底价只在寻源路径上有;A1/A2 直达的两条边不带它,A4 的 floors 非必填 - { target: 'floors', source: { from: 'output-port', port: 'compare', path: 'floors' } }, - ], reopen: false, }, - { id: 'E8', from: 'A4', to: 'A5', mapping: [], label: '报价草案提审', reopen: false }, + { id: 'E8', from: 'A4', to: 'A5', label: '报价草案提审', reopen: false }, /* A5 审批点自己的两条条件出边 —— 裁决(verdict)由引擎在裁决时放进载荷 */ { @@ -153,11 +129,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ condition: 'verdict = "pass"', label: '通过', tone: 'ok', - mapping: [ - { target: 'rows', source: { from: 'output-port', port: 'draft', path: 'rows' } }, - { target: 'quote_no', source: { from: 'output-port', port: 'draft', path: 'quote_no' } }, - { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, - ], reopen: false, }, { @@ -167,7 +138,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ condition: 'verdict = "reject"', label: '不通过', tone: 'bad', - mapping: REENTER_A4, // 回边:与 A5 的 reject 声明同一去向;reopen 必标,否则成环、拓扑序崩 reopen: true, }, @@ -180,7 +150,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ condition: 'outcome = "won"', label: '成交', tone: 'ok', - mapping: [], reopen: false, }, { @@ -190,7 +159,6 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ condition: 'outcome = "revise"', label: '有异议', tone: 'bad', - mapping: REENTER_A4, reopen: true, }, { @@ -200,16 +168,9 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ condition: 'outcome = "lost"', label: '拒绝/超时', tone: 'bad', - mapping: [ - // outcome 由放行选口注入落地事实,随每个发射口的载荷同行(不是 formal 声明的字段) - { target: 'outcome', source: { from: 'output-port', port: 'formal', path: 'outcome' } }, - { target: 'quote_no', source: { from: 'output-port', port: 'formal', path: 'quote_no' } }, - { target: 'customer_code', source: { from: 'output-port', port: 'formal', path: 'customer_code' } }, - { target: 'quotedAt', source: { from: 'initiation', path: 'quotedAt' } }, - ], reopen: false, }, - { id: 'E14', from: 'A7', to: 'end_arch', mapping: [], label: '归档闭环', reopen: false }, + { id: 'E14', from: 'A7', to: 'end_arch', label: '归档闭环', reopen: false }, ], variables: [], /* @@ -340,14 +301,6 @@ const TRIGGER_EVENT = triggerConfigSchema.parse({ name: '客户RFQ询价', enabled: true, processPackageId: 'unified-quote', - mapping: [ - { target: 'customerCode', source: { from: 'expression', sfeel: 'keys.customerCode' } }, - { target: 'rfqRef', source: { from: 'expression', sfeel: 'keys.rfqRef' } }, - { target: 'subject', source: { from: 'expression', sfeel: 'subject' } }, - { target: 'body', source: { from: 'expression', sfeel: 'body' } }, - // 单据日期取入站时刻:`intel.history_price` 的主键是 (pn, quoted_at),归档拿不到日期就写不进去 - { target: 'quotedAt', source: { from: 'expression', sfeel: 'occurredAt' } }, - ], // 按**通道**收口:同一封信投到「询价待确认邮箱」不命中,落人工确认那条路 spec: { kind: 'event', @@ -362,7 +315,6 @@ const TRIGGER_REGISTRATION = triggerConfigSchema.parse({ name: '人工登记起单(统一报价)', enabled: true, processPackageId: 'unified-quote', - mapping: [], spec: { kind: 'registration' }, }); diff --git a/apps/api/src/app/seed-mvp-slots.ts b/apps/api/src/app/seed-mvp-slots.ts index 0234cd4..9afb3e7 100644 --- a/apps/api/src/app/seed-mvp-slots.ts +++ b/apps/api/src/app/seed-mvp-slots.ts @@ -262,10 +262,11 @@ const identity = (activityId: string, name: string, paradigm: 'flow' | 'loop') = * 六个槽位一律 flow 范式;预算固定 humanCalls: 2。 * 0.6.1:修验收步的出参映射 —— 键是**台账列名**(小写蛇形),不是回传字段名。 * 0.7.0:A6 的三个去向改**选择组**(契约 0.2.0)—— outcome 由放行选口给出,G4 才判得了。 - * 契约的入参出口一直没动,故 `contract.version` 始终 0.1.0。 + * 0.8.0:对齐 Mastra 数据流 —— A2 的入参 `material` 更名 `pn`(它就是 PN): + * 边不再带映射,数据按字段名流动,上游发的名就得是下游收的名。 * 在途任务按启动时版本跑完,不受影响。 */ - packageVersion: '0.7.0', + packageVersion: '0.8.0', category: '统一报价', paradigm, sdkVersion: '0.1.0-m5', @@ -401,14 +402,14 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [ identity: identity('alt-selection', '替代选型', 'flow'), contract: { activityId: 'alt-selection', - // 0.2.0:入参加 rfqRef —— 办结时要回写「受理结论」那一行,主键就是它 - version: '0.2.0', + // 0.3.0:material 更名 pn(对齐 Mastra 名流动 —— 上游 A1 发的就是 pn) + version: '0.3.0', input: { data: { type: 'object', - required: ['material', 'rfqRef'], + required: ['pn', 'rfqRef'], properties: { - material: { type: 'string' }, + pn: { type: 'string' }, chip_type: { type: 'string' }, spec_params: { type: 'string' }, scenario: { type: 'string' }, @@ -464,7 +465,7 @@ export const MVP_SLOTS: readonly ActivityManifest[] = [ samples: [ { name: '契约样例:一件替代评估', - input: { material: 'LM358DR' }, + input: { pn: 'LM358DR' }, expect: { ports: ['benchmark'] }, }, ], diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 0b483d2..05bff74 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) @@ -25,6 +25,12 @@ 被依赖: trigger, app(治理台 · 工作流管理) 不变量(OS 03 / 3.2): + - **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射, + 只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域 + (起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取; + 约束力全在两端活动的输入/输出 schema 上 —— 名字即管道,闭合校验只拦 + 「同名不同型」这一条硬伤;必填有没有着落是运行时受理补全单的事,不拦激活。 + 改名映射不存在:上游发的名就得是下游收的名,名不对改**契约**,不是加映射。 - **宽进**(2026-07-30):`start` 不再按调用接口拒缺项 —— 输入可以不定或为空, 收敛点在活动(任务侧受理归一 + 补全单)。`CallInterfaceViolationError` 保留导出, 仅存量引用,起单路径不再抛它 diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index af9df45..d4dabbd 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -1,4 +1,4 @@ -/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */ +/** 闭合校验(名流动版)、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */ import { describe, expect, it } from 'vitest'; import type { ProcessPackage } from '@contracts'; import { checkClosure } from '../internal/domain/closure.ts'; @@ -39,17 +39,9 @@ const pkg = (over: Partial = {}): ProcessPackage => { id: 'end' as never, kind: 'end', label: '终点' }, ], edges: [ - { - id: 'e1', - from: 'start' as never, - to: 'A4' as never, - mapping: [ - { target: 'pn', source: { from: 'initiation', path: 'pn' } }, - { target: 'basePrice', source: { from: 'initiation', path: 'basePrice' } }, - ], - reopen: false, - }, - { id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false }, + // 边只表达顺序与条件(2026-07-30 对齐 Mastra)—— 数据按字段名流动,不带映射 + { id: 'e1', from: 'start' as never, to: 'A4' as never, reopen: false }, + { id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false }, ], variables: [], ...over, @@ -58,40 +50,32 @@ const pkg = (over: Partial = {}): ProcessPackage => const contracts = new Map([['a4', face()]]); describe('checkClosure —— 不闭合不得激活', () => { - it('必填全映射、岗位存在即可激活', () => { + it('结构闭合、活动在库即可激活 —— 必填有没有着落是运行时补全单的事,不拦激活', () => { const report = checkClosure({ pkg: pkg(), contracts }); expect(report.violations).toEqual([]); expect(report.closed).toBe(true); }); - it('边未完成映射即拦', () => { - const broken = pkg({ + it('同名字段类型相抵即拦 —— 名字即管道,名同型异必坏', () => { + // 上游 done 口发 quote: number …下游若声明 quote: string 就是硬伤 + const clash = new Map([ + ['a4', face({ inputTypes: { pn: 'string', quote: 'string' }, requiredInputs: ['pn'] })], + ]); + const chain = pkg({ + nodes: [ + { id: 'start' as never, kind: 'start', label: '起点' }, + { id: 'A3' as never, kind: 'activity', label: '上游', activityId: 'a4' as never, failure: { kind: 'terminate' } }, + { id: 'A4' as never, kind: 'activity', label: '下游', activityId: 'a4' as never, failure: { kind: 'terminate' } }, + { id: 'end' as never, kind: 'end', label: '终点' }, + ], edges: [ - { id: 'e1', from: 'start' as never, to: 'A4' as never, mapping: [], reopen: false }, - { id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false }, + { id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false }, + { id: 'e1', from: 'A3' as never, to: 'A4' as never, reopen: false }, + { id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false }, ], }); - const report = checkClosure({ pkg: broken, contracts }); - expect(report.closed).toBe(false); - expect(report.violations.map((v) => v.kind)).toContain('edge-unmapped'); - expect(report.violations.map((v) => v.kind)).toContain('required-unmapped'); - }); - - it('必填未齐即拦', () => { - const partial = pkg({ - edges: [ - { - id: 'e1', - from: 'start' as never, - to: 'A4' as never, - mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }], - reopen: false, - }, - { id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false }, - ], - }); - const violations = checkClosure({ pkg: partial, contracts }).violations; - expect(violations.filter((v) => v.kind === 'required-unmapped').map((v) => v.field)).toEqual(['basePrice']); + const violations = checkClosure({ pkg: chain, contracts: clash }).violations; + expect(violations.filter((v) => v.kind === 'type-conflict').map((v) => v.field)).toEqual(['quote']); }); it('引用未安装的活动即拦', () => { @@ -124,7 +108,7 @@ describe('checkClosure —— 不闭合不得激活', () => { edges: [ ...pkg().edges, // A4 已有一条无条件出边 A4→end;再加一条无条件出边即违规 - { id: 'e9', from: 'A4' as never, to: 'end2' as never, mapping: [], reopen: false }, + { id: 'e9', from: 'A4' as never, to: 'end2' as never, reopen: false }, ], }); const violations = checkClosure({ pkg: twoDefaults, contracts }).violations; @@ -161,18 +145,14 @@ describe('checkClosure —— 不闭合不得激活', () => { { id: 'end' as never, kind: 'end', label: '终点' }, ], edges: [ - { id: 'e0', from: 'start' as never, to: 'A3' as never, mapping: [{ target: 'pn', source: { from: 'initiation', path: 'pn' } }, { target: 'basePrice', source: { from: 'initiation', path: 'b' } }], reopen: false }, + { id: 'e0', from: 'start' as never, to: 'A3' as never, reopen: false }, { id: 'e1', from: 'A3' as never, to: 'A4' as never, - mapping: [ - { target: 'pn', source: { from: 'output-port', port: 'done', path: 'pn' } }, - { target: 'basePrice', source: { from: 'output-port', port: 'done', path: 'quote' } }, - ], reopen: false, }, - { id: 'e2', from: 'A4' as never, to: 'end' as never, mapping: [], reopen: false }, + { id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false }, ], }); const violations = checkClosure({ pkg: tolerant, contracts }).violations; diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index 7aa57ef..1fef039 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -40,7 +40,7 @@ export { createProcessService } from './internal/usecase/process-service.ts'; export { advanceOrder, - assemble, + flatScope, dispatchOf, rejectRouteOf, selectEdges, diff --git a/apps/api/src/modules/process/internal/adapter/mastra-runtime.ts b/apps/api/src/modules/process/internal/adapter/mastra-runtime.ts index 2903612..cf68704 100644 --- a/apps/api/src/modules/process/internal/adapter/mastra-runtime.ts +++ b/apps/api/src/modules/process/internal/adapter/mastra-runtime.ts @@ -29,7 +29,7 @@ import type { } from '../../port.ts'; import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts'; import type { NodeInstance } from '../domain/owner-resolution.ts'; -import { assemble, rejectRouteOf } from '../domain/routing.ts'; +import { flatScope, rejectRouteOf } from '../domain/routing.ts'; import { compilePackage } from './workflow-compiler.ts'; import type { CompiledFlow, RunResult } from './workflow-compiler.ts'; @@ -170,7 +170,7 @@ export function createMastraRuntime(deps: MastraRuntimeDeps): FlowRuntime { assembleEdge: async ({ workflowId, edge, payload }) => { const live = await requireWorkflow(workflowId); const scope = scopeOf(live); - return assemble(edge, { ...scope, ports: { ...scope.ports, [edge.from]: payload } }, deps.sfeel.expression); + return flatScope({ ...scope, ports: { ...scope.ports, [edge.from]: payload } }, payload); }, evaluate: async ({ workflowId, condition, payload }) => { @@ -435,7 +435,7 @@ export function createMastraRuntime(deps: MastraRuntimeDeps): FlowRuntime { if (verdict === 'reject') { const hasRejectEdge = pkg.edges.some((e) => e.from === nodeId && (e.condition ?? '').includes('reject')); if (!hasRejectEdge) { - const route = rejectRouteOf(node, scopeOf(record), deps.sfeel.expression); + const route = rejectRouteOf(node, scopeOf(record)); if (route.kind === 'terminate') { await deps.store.updateWorkflow(workflowId, { state: 'terminated', diff --git a/apps/api/src/modules/process/internal/domain/closure.ts b/apps/api/src/modules/process/internal/domain/closure.ts index e164072..8c44e33 100644 --- a/apps/api/src/modules/process/internal/domain/closure.ts +++ b/apps/api/src/modules/process/internal/domain/closure.ts @@ -55,7 +55,7 @@ export function checkClosure(input: ClosureInput): ClosureReport { } if (node.kind === 'activity') { - violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts)); + violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts, byId)); } // 岗位不入编排(活动点与审批点同一口径,2026-07-28 第五轮):没有岗位可核 /* @@ -86,6 +86,7 @@ function checkActivityNode( node: Extract, inbound: readonly Edge[], contracts: ReadonlyMap, + byId: ReadonlyMap, ): ClosureViolation[] { const out: ClosureViolation[] = []; const face = contracts.get(node.activityId); @@ -99,39 +100,28 @@ function checkActivityNode( return out; } - // 连一条边即须完成该边映射 + /* + * 数据按字段名流动(2026-07-30 对齐 Mastra):边不带映射,「必填有没有着落」 + * 不再是激活阻断 —— 宽进纪律下,缺的字段运行时由受理补全单找人收齐。 + * 激活闸只留 schema 咬合这一条硬伤:**上游产出与下游输入同名却不同型**, + * 名字既是管道,名同型异一定坏 —— 这必须在设计期拦下。 + */ for (const edge of inbound) { - if (edge.mapping.length === 0) { - out.push({ kind: 'edge-unmapped', edgeId: edge.id, nodeId: node.id, message: `边 ${edge.id} 未完成载荷映射` }); - } - } - - // 必填未映射 = 边未完成 - const mapped = new Set(inbound.flatMap((e) => e.mapping.map((m) => m.target))); - for (const required of face.requiredInputs) { - if (!mapped.has(required)) { - out.push({ - kind: 'required-unmapped', - nodeId: node.id, - field: required, - message: `节点「${node.label}」的必填字段未映射:${required}`, - }); - } - } - - // 类型兼容依两端契约 schema 当场校验 - for (const edge of inbound) { - for (const mapping of edge.mapping) { - const targetType = face.inputTypes[mapping.target]; - if (targetType === undefined || mapping.source.from !== 'output-port') continue; - const sourceType = contracts.get(node.activityId)?.outputs[mapping.source.port]?.[mapping.source.path]; - if (sourceType !== undefined && sourceType !== targetType) { - out.push({ - kind: 'type-conflict', - edgeId: edge.id, - field: mapping.target, - message: `边 ${edge.id} 字段 ${mapping.target} 类型冲突:${sourceType} → ${targetType}`, - }); + const upstream = byId.get(edge.from); + if (upstream?.kind !== 'activity') continue; + const upFace = contracts.get(upstream.activityId); + if (upFace === undefined) continue; + for (const [field, targetType] of Object.entries(face.inputTypes)) { + for (const portFields of Object.values(upFace.outputs)) { + const sourceType = portFields[field]; + if (sourceType !== undefined && sourceType !== targetType) { + out.push({ + kind: 'type-conflict', + edgeId: edge.id, + field, + message: `边 ${edge.id} 同名字段 ${field} 类型相抵:上游 ${sourceType} → 下游 ${targetType}(名字即管道,名同型异必坏)`, + }); + } } } } @@ -166,14 +156,20 @@ function checkTolerateDependencies( if (target?.kind !== 'activity') continue; const face = contracts.get(target.activityId); if (face === undefined) continue; - for (const mapping of edge.mapping) { - if (face.requiredInputs.includes(mapping.target)) { + const source = byId.get(edge.from); + if (source?.kind !== 'activity') continue; + const srcFace = contracts.get(source.activityId); + if (srcFace === undefined) continue; + // 按名判依赖:容忍分支产出的字段名恰是下游的必填 —— 分支可空,依赖它的必填就会空 + const produced = new Set(Object.values(srcFace.outputs).flatMap((fields) => Object.keys(fields))); + for (const required of face.requiredInputs) { + if (produced.has(required)) { out.push({ kind: 'tolerate-required-dependency', edgeId: edge.id, nodeId: target.id, - field: mapping.target, - message: `容忍分支 ${edge.from} 的输出被下游必填字段 ${mapping.target} 依赖 —— 汇合处必填映射不得依赖可空分支`, + field: required, + message: `容忍分支 ${edge.from} 产出的 ${required} 被下游必填依赖 —— 分支可空,依赖它的必填就会空`, }); } } diff --git a/apps/api/src/modules/process/internal/domain/routing.ts b/apps/api/src/modules/process/internal/domain/routing.ts index 14973c9..e5ac49d 100644 --- a/apps/api/src/modules/process/internal/domain/routing.ts +++ b/apps/api/src/modules/process/internal/domain/routing.ts @@ -17,50 +17,28 @@ export type Dispatch = /** 流程包显式终点:按输出声明交付、按来源回执 */ | { readonly kind: 'finish'; readonly node: Extract; readonly payload: Record }; -/** 边映射装配:上游输出口载荷 / 流程变量 / 起单载荷 / 常量 → 下游 data 字段 */ +/** 工作流作用域 —— 数据按**字段名**流动(对齐 Mastra):边不带映射,约束力在两端契约 schema 上 */ export type AssembleScope = { readonly ports: Readonly>>; readonly vars: Readonly>; readonly initiation: Readonly>; }; -export function assemble( - edge: Edge, - scope: AssembleScope, - evaluateExpression: (sfeel: string, scope: Readonly>) => unknown, -): Record { - const out: Record = {}; - const flat: Record = { ...scope.initiation, ...scope.vars, ...scope.ports }; - for (const mapping of edge.mapping) { - switch (mapping.source.from) { - case 'output-port': - out[mapping.target] = pick(scope.ports[mapping.source.port], mapping.source.path); - break; - case 'workflow-var': - out[mapping.target] = scope.vars[mapping.source.name]; - break; - case 'initiation': - out[mapping.target] = pick(scope.initiation, mapping.source.path); - break; - case 'constant': - out[mapping.target] = mapping.source.value; - break; - case 'expression': - out[mapping.target] = evaluateExpression(mapping.source.sfeel, flat); - break; - } +/** + * 铺平作用域:起单入参垫底,沿途各输出口的字段依次盖上(后发生的赢), + * 最后是**刚发射的载荷**(Mastra 语义:下一步的 input 首先是上一步的 output)。 + * `__` 前缀是运行时自留地(胶合层的 __runSeq / 终点的 __output),不入业务作用域。 + */ +export function flatScope(scope: AssembleScope, latest: Record = {}): Record { + const out: Record = { ...scope.initiation }; + for (const [port, payload] of Object.entries(scope.ports)) { + if (port.startsWith('__')) continue; + if (payload !== null && typeof payload === 'object') Object.assign(out, payload); } + Object.assign(out, latest); + for (const key of Object.keys(out)) if (key.startsWith('__')) delete out[key]; return out; } - -function pick(source: Readonly> | undefined, path: string): unknown { - if (source === undefined) return undefined; - return path.split('.').reduce((acc, seg) => { - if (acc === null || acc === undefined || typeof acc !== 'object') return undefined; - return (acc as Record)[seg]; - }, source); -} - /** * 从某节点出发,算出接下来该走的边。 * XOR:条件求值,**恰取一条**;AND:全部出边同时走(分叉)。 @@ -102,18 +80,10 @@ export type RejectRoute = export function rejectRouteOf( node: Extract, scope: AssembleScope, - evaluateExpression: (sfeel: string, scope: Readonly>) => unknown, ): RejectRoute { if (node.reject.kind === 'terminate') return { kind: 'terminate' }; - return { - kind: 'reopen', - nodeId: node.reject.nodeId, - payload: assemble( - { id: `${node.id}-reject`, from: node.id, to: node.reject.nodeId, mapping: node.reject.mapping, reopen: true }, - scope, - evaluateExpression, - ), - }; + // 重开载荷 = 铺平作用域 —— 重入的节点按自己的输入契约取字段,不靠这里挑 + return { kind: 'reopen', nodeId: node.reject.nodeId, payload: flatScope(scope) }; } /** 起点:找出流程包的入口节点 */ diff --git a/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts b/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts index 15f6c41..abde183 100644 --- a/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts +++ b/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts @@ -25,7 +25,7 @@ import type { } from '../../port.ts'; import { aggregateWorkflowState, isDigested, nextInstanceNumber, resolveOwner } from '../domain/owner-resolution.ts'; import type { NodeInstance } from '../domain/owner-resolution.ts'; -import { assemble, dispatchOf, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts'; +import { dispatchOf, flatScope, rejectRouteOf, selectEdges, startNodeOf } from '../domain/routing.ts'; export type SelfHostedRuntimeDeps = { readonly store: ProcessStore; @@ -231,7 +231,8 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim * 节点只收**装配后的**载荷:活动与审批点都有契约,不被上游多余字段污染; * 判据要用的隐性上下文(审批 verdict、放行选口 outcome)由系统注入判据作用域,不走映射。 */ - const arriving = assemble(edge, { ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, deps.sfeel.expression); + // 数据按字段名流动(对齐 Mastra):边不带映射 —— 下游收铺平作用域,按自己的契约取字段 + const arriving = flatScope({ ...scope, ports: { ...scope.ports, [fromNodeId]: payload } }, payload); try { await enter(record, pkg, target, arriving); } catch (error) { @@ -302,8 +303,8 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim const edge = pkg.edges.find((e) => e.id === disposition.edgeId); const target = edge === undefined ? undefined : pkg.nodes.find((n) => n.id === edge.to); if (edge !== undefined && target !== undefined) { - // 失败载荷 = 终态、原因码与说明 - const payload = assemble(edge, scopeOf(record), deps.sfeel.expression); + // 失败改道:载荷照样是铺平作用域 + 终态标记 —— 接失败的节点也按自己的契约取字段 + const payload = flatScope(scopeOf(record)); await enter(record, pkg, target, { ...payload, __terminal: terminal }); } } @@ -401,7 +402,7 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim */ const routedByEdge = await advanceFrom(record, pkg, nodeId, carried, true); if (!routedByEdge) { - const route = rejectRouteOf(node, scopeOf(record), deps.sfeel.expression); + const route = rejectRouteOf(node, scopeOf(record)); if (route.kind === 'terminate') { await deps.store.updateWorkflow(workflowId, { state: 'terminated', finishedAt: deps.clock.now().toISOString() }); } else { diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 77f9077..43b1937 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -665,6 +665,19 @@ .g-maprow > .g-btn { grid-column: 6; justify-self: stretch; padding: 4px 0; display: inline-flex; align-items: center; justify-content: center; } .g-maparr { color: var(--sh-steel, #46586a); font-weight: 700; text-align: center; } +/* ── 节点属性 · 数据接口:契约即管道 —— 输入字段(名/型/来源)+ 输出口 ── */ +.g-dif { display: flex; flex-direction: column; gap: 2px; } +.g-dif + .g-dif { margin-top: 8px; } +.g-dif > em { font: 600 9px/1.6 var(--mono); font-style: normal; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-4); padding-bottom: 3px; border-bottom: 1px solid var(--line-soft); } +.g-dif-r { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; padding: 4px 0; border-bottom: 1px dashed var(--line-soft); font-size: 11.5px; } +.g-dif-r:last-child { border-bottom: 0; } +.g-dif-r .k { font-family: var(--mono); font-weight: 700; color: var(--ink); } +.g-dif-r .k i { font-style: normal; font-family: var(--mono); font-size: 8.5px; font-weight: 600; letter-spacing: .06em; color: #8a5f1c; margin-left: 5px; padding: 1px 4px; border: 1px solid #e3d1a8; border-radius: 3px; } +.g-dif-r .t { font-family: var(--mono); font-size: 10px; color: var(--ink-4); } +.g-dif-r .s { margin-left: auto; font-size: 11px; color: var(--ink-3); } +.g-dif-r .s.ask { color: #8a5f1c; } +.g-dif-r .f { min-width: 0; font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); overflow-wrap: anywhere; } + /* ── 运营中心 · 工作流 · 运行配置:一包一卡,里面两节(触发器 / 节点负责人)── */ .g-runconf { display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 12px; } .g-runconf + .g-runconf { border-top: 1px dashed var(--line-soft); } diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index 9fabb0f..4894b06 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -25,7 +25,6 @@ import { Chip, Field, Kpi, - ListEdit, Note, PageHead, Prop, @@ -65,24 +64,20 @@ function flowsOf(packages: readonly ProcessPackage[]) { }); } -/* ── 边属性:传递契约(条件 + 映射)──────────────────────────────────── */ - -/** 映射行的工作形状 —— 语义边 mapping 的元素;设计者只见三种来源,其余是系统隐性上下文 */ -type MapRow = { target: string; source: Record & { from: string } }; -const SOURCE_KINDS = [ - { k: 'output-port', label: '上游输出口' }, - { k: 'initiation', label: '起单载荷' }, - { k: 'constant', label: '常量' }, -] as const; +/* ── 边属性:只剩顺序与条件(2026-07-30 对齐 Mastra)──────────────────── + Mastra 的边不传数据 —— 约束力全在两端活动的输入/输出字段(schema 咬合)。 + 数据按字段名流动:上游发的名就是下游收的名;边属性卡因此只剩条件与标签, + 「映射 · 载荷装配」整节退役 —— 下游拿什么去**节点属性**的数据接口看。 */ function EdgeProps({ edge, onPatch }: { edge: FlowEdge; onPatch: (f: (e: FlowEdge) => FlowEdge) => void }) { - const rows = (edge.mapping ?? []) as MapRow[]; - const setRows = (next: MapRow[]) => onPatch((e) => ({ ...e, mapping: next })); - const patchRow = (i: number, f: (r: MapRow) => MapRow) => setRows(rows.map((r, j) => (j === i ? f(r) : r))); return (
- - + + - - {/* 明细表列头:行与列头同一 6 列栅格;非「上游输出口」的来源在口/路径两列上合并 */} - - {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: '' } }]), - }, - ]} - /> -
); } @@ -506,6 +406,49 @@ function FlowLab({ (node.act === undefined && semanticNode?.kind === 'activity' && a.activityId === semanticNode.activityId), ); + /* + * 数据接口的来源推导(对齐 Mastra 名流动):这个节点的每个输入字段,运行时会从哪来 —— + * ① 起单入参里有同名字段 → 起单带进;② 某个上游(沿边回溯)的输出口发同名字段 → 上游发; + * ③ 都没有 → 受理归一时派补全单找人收。设计期看得见,不必等跑起来猜。 + */ + const fieldSources = (() => { + if (node === null) return new Map(); + const initFields = new Set( + Object.keys(((pkg.callInterface.input as { properties?: Record }).properties ?? {})), + ); + // 祖先集:沿边回溯(含回边,访问集防环) + const ancestors = new Set(); + const queue = [node.id]; + while (queue.length > 0) { + const cur = queue.pop()!; + for (const e of graph?.edges ?? []) { + if (e.to !== cur || ancestors.has(e.from)) continue; + ancestors.add(e.from); + queue.push(e.from); + } + } + const actOf = (nid: string): string | undefined => { + const g = nodes.find((x) => x.id === nid); + if (g?.act !== undefined) return g.act; + const sem = pkg.nodes.find((x) => x.id === nid); + return sem?.kind === 'activity' ? sem.activityId : undefined; + }; + const out = new Map(); + for (const f of refAct?.contract.inputFields ?? []) { + if (initFields.has(f.name)) { + out.set(f.name, '起单入参'); + continue; + } + const from = [...ancestors].find((nid) => + activities + .find((a) => a.activityId === actOf(nid)) + ?.contract.outputPorts.some((port) => port.fields.includes(f.name)), + ); + out.set(f.name, from !== undefined ? `上游 ${from}` : '补全单'); + } + return out; + })(); + // 每次进页问一次闭合 —— 画图即校验(与后端裁决同源);落笔保存后用返回的报告 const liveReport = useQuery({ queryKey: ['gov', 'process-report', pkg.id, pkg.version], @@ -661,17 +604,16 @@ function FlowLab({ label: n.label, activityId: n.activityId, // 契约不带负责人岗位 —— 分派归运营,依赖文件只描述干什么 - // 编排上手写的注记(给人读) - declaredIn: n.in ?? [], - declaredOut: n.out ?? [], /* * 活动库里那份**真契约**:必填入参、字段、输出口、声明的工具/模型/域对象。 * 开发者照着它写实现;只给注记的话,依赖文件说不清这个活动到底要什么。 */ contract: actContract.get(n.activityId) ?? null, - upstreamAssembly: pkg.edges - .filter((e) => e.to === n.id) - .map((e) => ({ from: e.from, mapping: e.mapping })), + /* + * 数据按字段名流动(对齐 Mastra):依赖文件不再列边映射 —— + * 上游从哪来,看 contract.input 的字段名对上游 output 的字段名。 + */ + upstream: pkg.edges.filter((e) => e.to === n.id).map((e) => e.from), })), approvals: pkg.nodes .filter((n) => n.kind === 'approval') @@ -1038,22 +980,50 @@ function FlowLab({ )} - {node.kind === 'activity' && ( - - patch((n) => ({ ...n, in: v.length > 0 ? [...v] : undefined }))} - /> - - )} - {node.kind === 'activity' && ( - - patch((n) => ({ ...n, out: v.length > 0 ? [...v] : undefined }))} - /> + {node.kind === 'activity' && node.flavor !== 'approval' && ( + /* + * 数据接口(对齐 Mastra):边不传数据,约束力全在输入/输出字段 —— + * 这里不填写、只**看清**:每个输入字段运行时从哪来(起单 / 上游同名 / 补全单), + * 每个输出口发什么名。要改字段,改的是活动契约,不是这张卡。 + */ + + {refAct === undefined ? ( + 链接活动实现后,这里列出它吃什么、吐什么、每个字段从哪来。 + ) : ( + <> +
+ 输入 · {refAct.contract.inputFields.length} 字段 + {refAct.contract.inputFields.map((f) => { + const src = fieldSources.get(f.name) ?? '补全单'; + return ( +
+ + {f.name} + {f.required && 必填} + + {f.type} + + {src === '补全单' ? '← 补全单(受理归一找人收)' : `← ${src}`} + +
+ ); + })} +
+
+ 输出 · {refAct.contract.outputPorts.length} 口 + {refAct.contract.outputPorts.map((port) => ( +
+ {port.name} + {port.fields.length > 0 ? port.fields.join(' · ') : '(整包事实)'} +
+ ))} +
+ + )}
)} diff --git a/apps/web/src/shared/gov/flow/__tests__/graph-to-pkg.test.ts b/apps/web/src/shared/gov/flow/__tests__/graph-to-pkg.test.ts index 37b910b..62970fc 100644 --- a/apps/web/src/shared/gov/flow/__tests__/graph-to-pkg.test.ts +++ b/apps/web/src/shared/gov/flow/__tests__/graph-to-pkg.test.ts @@ -25,11 +25,11 @@ const basePkg: ProcessPackage = processPackageSchema.parse({ { id: 'E', kind: 'end', label: '终' }, ], edges: [ - { id: 'E1', from: 'S', to: 'A', mapping: [{ target: 'x', source: { from: 'initiation', path: 'x' } }] }, - { id: 'E2', from: 'A', to: 'B', mapping: [] }, + { id: 'E1', from: 'S', to: 'A' }, + { id: 'E2', from: 'A', to: 'B' }, // 乙的两条互斥出边:通过 → 终点;有异议 → 回甲重开(无网关:分支就在节点身上) - { id: 'E3', from: 'B', to: 'E', condition: 'ok = true', mapping: [] }, - { id: 'E4', from: 'B', to: 'A', condition: 'ok = false', mapping: [], reopen: true }, + { id: 'E3', from: 'B', to: 'E', condition: 'ok = true' }, + { id: 'E4', from: 'B', to: 'A', condition: 'ok = false', reopen: true }, ], variables: [], }); @@ -73,9 +73,8 @@ describe('mergeGraphIntoPackage', () => { expect(minted.sort()).toEqual(['E5', 'E6']); }); - it('画布装不下的声明原样保留:边映射与失败走向', () => { + it('画布装不下的声明原样保留:失败走向(边映射已随「名流动」退役)', () => { const merged = mergeGraphIntoPackage(basePkg, graph); - expect(merged.edges.find((e) => e.id === 'E1')?.mapping).toHaveLength(1); const a = merged.nodes.find((n) => n.id === 'A'); expect(a?.kind === 'activity' && a.failure.kind).toBe('terminate'); }); @@ -95,12 +94,12 @@ describe('mergeGraphIntoPackage', () => { expect(b?.kind === 'activity' && b.activityId).toBe('act-b'); }); - it('边的条件/映射工作副本优先;清空条件 = 变默认兜底边', () => { + 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, condition: 'ok = true and sum > 3' } : e.from === 'B' && e.to === 'A' ? { ...e, condition: '' } // 设计者把条件清空:这条边成了默认兜底 : e, @@ -109,10 +108,7 @@ describe('mergeGraphIntoPackage', () => { const merged = mergeGraphIntoPackage(basePkg, edited); const pass = merged.edges.find((e) => e.id === 'E3'); expect(pass?.condition).toBe('ok = true and sum > 3'); - expect(pass?.mapping).toHaveLength(1); const back = merged.edges.find((e) => e.id === 'E4'); expect(back?.condition).toBeUndefined(); - // 没动过的边沿用旧声明 - expect(merged.edges.find((e) => e.id === 'E1')?.mapping).toHaveLength(1); }); }); diff --git a/apps/web/src/shared/gov/flow/graph-to-pkg.ts b/apps/web/src/shared/gov/flow/graph-to-pkg.ts index 83344e8..5c9282c 100644 --- a/apps/web/src/shared/gov/flow/graph-to-pkg.ts +++ b/apps/web/src/shared/gov/flow/graph-to-pkg.ts @@ -103,13 +103,12 @@ export function mergeGraphIntoPackage( .filter((e) => semanticIds.has(e.from) && semanticIds.has(e.to)) .map((e) => { const prior = priorEdge.get(`${e.from}→${e.to}`); - // 边属性(传递契约)以属性卡的工作副本优先;没动过的沿用旧声明 + // 边只带顺序与条件(对齐 Mastra):条件以属性卡的工作副本优先;没动过的沿用旧声明 const condition = e.condition !== undefined ? (e.condition.trim() === '' ? undefined : e.condition) : prior?.condition; return { id: prior?.id ?? `E${nextEdgeNo++}`, from: nodeIdSchema.parse(e.from), to: nodeIdSchema.parse(e.to), - mapping: (e.mapping as Edge['mapping'] | undefined) ?? prior?.mapping ?? [], ...(condition === undefined ? {} : { condition }), reopen: prior?.reopen ?? false, ...(e.label === undefined ? {} : { label: e.label }), diff --git a/apps/web/src/shared/gov/flow/pkg-to-graph.ts b/apps/web/src/shared/gov/flow/pkg-to-graph.ts index 0399462..a33c271 100644 --- a/apps/web/src/shared/gov/flow/pkg-to-graph.ts +++ b/apps/web/src/shared/gov/flow/pkg-to-graph.ts @@ -71,7 +71,6 @@ export function packageToGraph(pkg: ProcessPackage, marks: readonly InstanceMark ...e, code: sem.id as string, ...(sem.condition === undefined ? {} : { condition: sem.condition }), - mapping: sem.mapping as unknown[], }; }); return { ...stored, edges, nodes: [...withState(stored.nodes), ...withState(extras)] }; diff --git a/apps/web/src/shared/gov/gov-route.ts b/apps/web/src/shared/gov/gov-route.ts index 22d89fe..bad977d 100644 --- a/apps/web/src/shared/gov/gov-route.ts +++ b/apps/web/src/shared/gov/gov-route.ts @@ -121,7 +121,7 @@ export const ZM: Readonly; /* ── 边与载荷映射 ────────────────────────────────────────────────────── */ /** - * 映射来源四类:上游输出口载荷字段 / 流程变量 / 起单载荷 / 常量; - * 允许 S-FEEL 表达式做转换(与触发器字段映射同一形式)。 + * 字段映射 —— **只用于触发器的起单装配**(外界事件字段 → 调用接口字段,系统边界)。 + * 流程内部**不用它**(2026-07-30,对齐 Mastra):边只表达顺序, + * 数据靠输入/输出字段名流动,约束力在两端活动契约的 schema 上。 */ export const mappingSourceSchema = z.discriminatedUnion('from', [ z.object({ from: z.literal('output-port'), port: z.string().min(1), path: z.string().min(1) }), @@ -65,10 +66,12 @@ export const edgeSchema = z.object({ id: z.string().min(1), from: nodeIdSchema, to: nodeIdSchema, - /** XOR 网关出边的条件(S-FEEL);AND 出边留空 */ + /** + * XOR 出边的条件(S-FEEL),读上游节点的输出字段;无条件即默认兜底。 + * 边**只表达顺序与条件**(对齐 Mastra):不带映射 —— 数据靠字段名流动, + * 下游拿什么由它自己的输入契约声明,约束力在两端 schema 上。 + */ condition: z.string().optional(), - /** 连一条边即须完成该边映射 —— 必填未映射 = 边未完成 */ - mapping: z.array(fieldMappingSchema).default([]), /** 边标签:画布上的出口名(如「TYPE-A」「通过」) */ label: z.string().optional(), /** 边标签色调(呈现):ok = 绿(通过类) / bad = 红(驳回类) */ @@ -172,7 +175,7 @@ export const processNodeSchema = z.discriminatedUnion('kind', [ */ /** 驳回走向:声明式,不由临场决定 */ reject: z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('reopen'), nodeId: nodeIdSchema, mapping: z.array(fieldMappingSchema).default([]) }), + z.object({ kind: z.literal('reopen'), nodeId: nodeIdSchema }), z.object({ kind: z.literal('terminate') }), ]), nudge: nudgeLadderSchema.optional(), @@ -230,12 +233,12 @@ export type ProcessPackage = z.infer; /* ── 编排校验的报告形状(前端画布即时预览与后端裁决同源) ───────────── */ export const CLOSURE_VIOLATION_KINDS = [ - 'edge-unmapped', - 'required-unmapped', + /** 相邻两活动同名字段类型相抵 —— schema 咬合是唯一的数据约束,名同型异必坏 */ 'type-conflict', 'dangling-node', /** 多出边的无条件默认兜底超过一条 —— 第二条默认永远走不到 */ 'multi-default-edge', + /** 容忍分支的产出被下游必填依赖 —— 分支可空,依赖它的必填就会空 */ 'tolerate-required-dependency', 'unknown-activity', ] as const;