From a919d2e9618dabeb5b83880bf4a5882e5729758d Mon Sep 17 00:00:00 2001 From: JCAI Main Agent Date: Mon, 27 Jul 2026 01:55:47 +0800 Subject: [PATCH] =?UTF-8?q?M5(1/3):=20process=20=E7=9A=84=20domain=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E9=97=AD=E5=90=88=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E3=80=81=E5=9B=9B=E7=AD=96=E7=95=A5=E8=A7=A3=E6=9E=90=E3=80=81?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E8=81=9A=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkClosure:七类违规(边未映射 / 必填未齐 / 类型冲突 / 孤立节点 / 网关不平衡 / 汇合来源不明 / 容忍分支被必填依赖)+ 活动未安装 + 岗位不存在; 不闭合不得发布 —— 画布即时校验与后端裁决同源 - resolveOwner:四策略封闭(指定人 / 岗位唯一人 / 轮值 / 来源跟随); 岗上多人退轮值,并列按 staffId 稳定排序 —— 解析必须确定性 - aggregateWorkflowState:聚合投影,流程层不另设状态机;计量对象是节点实例, XOR 未选分支不入集合,改道/容忍/升审批已消化即不入异常 - nextInstanceNumber:流级重做重开新实例,不复活旧任务 325 个测试通过,门禁五道全绿 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- apps/api/src/modules/process/CONTRACT.md | 31 ++ .../modules/process/__tests__/closure.test.ts | 293 ++++++++++++++++++ apps/api/src/modules/process/contract.ts | 40 +++ apps/api/src/modules/process/index.ts | 26 ++ .../process/internal/domain/closure.ts | 218 +++++++++++++ .../internal/domain/owner-resolution.ts | 137 ++++++++ apps/api/src/modules/process/package.json | 1 + docs/PROGRESS.md | 25 +- 8 files changed, 769 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/modules/process/CONTRACT.md create mode 100644 apps/api/src/modules/process/__tests__/closure.test.ts create mode 100644 apps/api/src/modules/process/contract.ts create mode 100644 apps/api/src/modules/process/index.ts create mode 100644 apps/api/src/modules/process/internal/domain/closure.ts create mode 100644 apps/api/src/modules/process/internal/domain/owner-resolution.ts create mode 100644 apps/api/src/modules/process/package.json diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md new file mode 100644 index 0000000..62e9f85 --- /dev/null +++ b/apps/api/src/modules/process/CONTRACT.md @@ -0,0 +1,31 @@ +# module: process + +职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图即校验 · 不闭合不得发布)、 + 负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开 +不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、 + 画布的绘制(见 apps/web 的 gov-process) + +公开出口: index.ts + - 类型:`ClosureInput` `ClosureReport` `ClosureViolation` `ActivityContractFace` + `Candidate` `Resolution` `NodeInstance` `WorkflowState` + - 错误:`NotPublishableError` `PositionVacantError` + - 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState` + `isDigested` `nextInstanceNumber` + +层级: L7(编码规范 §4) +依赖: @contracts;task(L6)、@sfeel +被依赖: trigger, app(治理台 · 工作流管理) + +不变量(OS 03 / 3.2): + - **不闭合不得发布**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、 + 汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `publishable = false` + - **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure` + - **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕 + - **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**, + 重开产生新实例、旧实例不再入算;XOR 未选分支从未激活、不入集合 + - **失败走向的消化**:改道 / 容忍 / 升审批一经生效即消化;终止流待人工处置中算异常 + - **流级重做一律重开新实例** —— 同节点、新任务 ID、留痕关联,不复活旧任务 + +## M5 待补(docs/PROGRESS.md) +port.ts 与 usecase:流程包的存取与发布、工作流实例的起单与流转 +(Mastra `createWorkflow` 编译在 `internal/adapter/workflow-compiler.ts`,编码规范 §6.1)。 diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts new file mode 100644 index 0000000..75d1327 --- /dev/null +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -0,0 +1,293 @@ +/** 边映射闭合校验、负责人四策略、工作流状态聚合 —— domain 测试零 mock(OS 03 / 3.2) */ +import { describe, expect, it } from 'vitest'; +import type { ProcessPackage } from '@contracts'; +import { checkClosure } from '../internal/domain/closure.ts'; +import type { ActivityContractFace } from '../internal/domain/closure.ts'; +import { + aggregateWorkflowState, + isDigested, + nextInstanceNumber, + resolveOwner, +} from '../internal/domain/owner-resolution.ts'; +import type { Candidate, NodeInstance } from '../internal/domain/owner-resolution.ts'; + +const face = (over: Partial = {}): ActivityContractFace => ({ + activityId: 'a4', + requiredInputs: ['pn', 'basePrice'], + inputTypes: { pn: 'string', basePrice: 'number' }, + outputs: { done: { pn: 'string', quote: 'number' } }, + ...over, +}); + +/** 统一报价的一小段:起点 → A4 成本定价 → 终点 */ +const pkg = (over: Partial = {}): ProcessPackage => + ({ + id: 'quote' as never, + name: '统一报价', + version: 1, + state: 'draft', + callInterface: { input: {}, output: {} }, + nodes: [ + { id: 'start' as never, kind: 'start', label: '起点' }, + { + id: 'A4' as never, + kind: 'activity', + label: 'A4 成本定价', + activityId: 'a4' as never, + owner: { position: 'BD', strategy: 'sole' }, + failure: { kind: 'terminate' }, + }, + { 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 }, + ], + variables: [], + ...over, + }) as ProcessPackage; + +const contracts = new Map([['a4', face()]]); +const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']); + +describe('checkClosure —— 不闭合不得发布', () => { + it('必填全映射、岗位存在即可发布', () => { + const report = checkClosure({ pkg: pkg(), contracts, positions }); + expect(report.violations).toEqual([]); + expect(report.publishable).toBe(true); + }); + + it('边未完成映射即拦', () => { + const broken = pkg({ + 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 }, + ], + }); + const report = checkClosure({ pkg: broken, contracts, positions }); + expect(report.publishable).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, positions }).violations; + expect(violations.filter((v) => v.kind === 'required-unmapped').map((v) => v.field)).toEqual(['basePrice']); + }); + + it('引用未安装的活动即拦', () => { + const report = checkClosure({ pkg: pkg(), contracts: new Map(), positions }); + expect(report.violations.map((v) => v.kind)).toContain('unknown-activity'); + }); + + it('岗位不存在即拦', () => { + const report = checkClosure({ pkg: pkg(), contracts, positions: new Set(['PM']) }); + expect(report.violations.map((v) => v.kind)).toContain('missing-owner-position'); + }); + + it('孤立节点即拦', () => { + const orphan = pkg({ + nodes: [ + ...pkg().nodes, + { + id: 'A9' as never, + kind: 'activity', + label: '游离节点', + activityId: 'a4' as never, + owner: { position: 'BD', strategy: 'sole' }, + failure: { kind: 'terminate' }, + }, + ], + }); + const violations = checkClosure({ pkg: orphan, contracts, positions }).violations; + expect(violations.filter((v) => v.kind === 'dangling-node').length).toBeGreaterThan(0); + }); + + it('异或网关的出边必须有条件 —— 声明式、静态可检', () => { + 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 }, + { id: 'g3', from: 'G2' as never, to: 'end' as never, mapping: [], reopen: false }, + ], + }); + const violations = checkClosure({ pkg: withGateway, contracts, positions }).violations; + expect(violations.some((v) => v.message.includes('缺条件'))).toBe(true); + }); + + it('容忍分支的输出不得被下游必填依赖', () => { + const tolerant = pkg({ + nodes: [ + { id: 'start' as never, kind: 'start', label: '起点' }, + { + id: 'A3' as never, + kind: 'activity', + label: 'A3 渠道寻源', + activityId: 'a4' as never, + owner: { position: 'PRM', strategy: 'sole' }, + failure: { kind: 'tolerate' }, + }, + { + id: 'A4' as never, + kind: 'activity', + label: 'A4 成本定价', + activityId: 'a4' as never, + owner: { position: 'BD', strategy: 'sole' }, + failure: { kind: 'terminate' }, + }, + { 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: '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 }, + ], + }); + const violations = checkClosure({ pkg: tolerant, contracts, positions }).violations; + expect(violations.map((v) => v.kind)).toContain('tolerate-required-dependency'); + }); +}); + +describe('resolveOwner —— 四策略确定性解析', () => { + const many: Candidate[] = [ + { staffId: 's2', name: '乙', openTasks: 3 }, + { staffId: 's1', name: '甲', openTasks: 1 }, + { staffId: 's3', name: '丙', openTasks: 1 }, + ]; + + it('岗位唯一人', () => { + const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [many[1]!] }); + expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'sole' }); + }); + + it('岗上多人时按轮值 —— 在途最少者优先', () => { + const r = resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: many }); + expect(r).toMatchObject({ kind: 'resolved', staffId: 's1', by: 'rotation' }); + }); + + it('轮值并列时按 staffId 稳定排序 —— 解析必须确定性', () => { + const a = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: many }); + const b = resolveOwner({ position: 'BD', strategy: 'rotation' }, { candidates: [...many].reverse() }); + expect(a).toEqual(b); + }); + + it('指定人:不在候选内即空缺', () => { + expect( + resolveOwner({ position: 'BD', strategy: 'assigned', staffId: 'sX' as never }, { candidates: many }), + ).toEqual({ kind: 'vacant', position: 'BD' }); + }); + + it('来源跟随:复用同流内已解析出的人', () => { + const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many, alreadyResolved: 's2' }); + expect(r).toMatchObject({ kind: 'resolved', staffId: 's2', by: 'follow-source' }); + }); + + it('来源跟随:此前没解析过即退到轮值', () => { + const r = resolveOwner({ position: 'BD', strategy: 'follow-source' }, { candidates: many }); + expect(r).toMatchObject({ by: 'rotation' }); + }); + + it('岗位空缺', () => { + expect(resolveOwner({ position: 'BD', strategy: 'sole' }, { candidates: [] })).toEqual({ + kind: 'vacant', + position: 'BD', + }); + }); +}); + +describe('aggregateWorkflowState —— 聚合投影,流程层不另设状态机', () => { + const inst = (over: Partial = {}): NodeInstance => ({ + nodeId: 'A1', + instance: 1, + running: false, + waiting: false, + activated: true, + ...over, + }); + + it('任一实例运行中 → 运行中', () => { + expect(aggregateWorkflowState([inst({ running: true }), inst({ terminal: 'released' })], false)).toBe('running'); + }); + + it('无运行实例但有待人 / 监听 → 等待中', () => { + expect(aggregateWorkflowState([inst({ waiting: true })], false)).toBe('waiting'); + }); + + it('全部收敛 → 已完成', () => { + expect(aggregateWorkflowState([inst({ terminal: 'released' }), inst({ terminal: 'released' })], false)).toBe( + 'completed', + ); + }); + + it('非完成终态且失败走向未消化 → 异常', () => { + expect(aggregateWorkflowState([inst({ terminal: 'blocked' })], false)).toBe('abnormal'); + }); + + it('改道 / 容忍 / 升审批已消化即不入异常', () => { + expect(aggregateWorkflowState([inst({ terminal: 'blocked', failureDigested: true })], false)).toBe('completed'); + }); + + it('XOR 未选分支从未激活,不入集合', () => { + expect( + aggregateWorkflowState([inst({ terminal: 'released' }), inst({ activated: false, terminal: 'blocked' })], false), + ).toBe('completed'); + }); + + it('控制事件终止 → 已终止', () => { + expect(aggregateWorkflowState([inst({ running: true })], true)).toBe('terminated'); + }); +}); + +describe('失败走向的消化与重开', () => { + it('终止流待人工处置中仍算异常', () => { + expect(isDigested('terminate')).toBe(false); + expect(isDigested('reroute')).toBe(true); + expect(isDigested('tolerate')).toBe(true); + expect(isDigested('escalate')).toBe(true); + }); + + it('重开产生新实例号 —— 不复活旧任务', () => { + const instances: NodeInstance[] = [ + { nodeId: 'A4', instance: 1, running: false, waiting: false, activated: true, terminal: 'released' }, + { nodeId: 'A4', instance: 2, running: true, waiting: false, activated: true }, + { nodeId: 'A6', instance: 1, running: true, waiting: false, activated: true }, + ]; + expect(nextInstanceNumber(instances, 'A4')).toBe(3); + expect(nextInstanceNumber(instances, 'A1')).toBe(1); + }); +}); diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts new file mode 100644 index 0000000..27a80aa --- /dev/null +++ b/apps/api/src/modules/process/contract.ts @@ -0,0 +1,40 @@ +/** + * module: process —— 对外类型(编码规范 §10.1;语义出处 OS 03)。 + * + * 流程层:流程包定义与版本、**边映射闭合校验**、工作流实例 + * (网关 / 审批点 / 失败走向 / 负责人四策略 / 聚合)。 + * + * M5 进行中:domain 已落(闭合校验 / 四策略解析 / 状态聚合), + * 用例与 Mastra 工作流编译随后(编码规范 §6.1)。 + */ +import type { ClosureReport, ClosureViolation, OwnerSpec, ProcessPackage, WorkflowState } from '@contracts'; +import type { ActivityContractFace, ClosureInput } from './internal/domain/closure.ts'; +import type { Candidate, NodeInstance, ResolveContext, Resolution } from './internal/domain/owner-resolution.ts'; + +export type { + ActivityContractFace, + Candidate, + ClosureInput, + ClosureReport, + ClosureViolation, + NodeInstance, + OwnerSpec, + ProcessPackage, + ResolveContext, + Resolution, + WorkflowState, +}; + +export class NotPublishableError extends Error { + constructor(readonly report: ClosureReport) { + super(`流程包不闭合,不得发布:${report.violations.map((v) => v.message).join('; ')}`); + this.name = 'NotPublishableError'; + } +} + +export class PositionVacantError extends Error { + constructor(readonly position: string) { + super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`); + this.name = 'PositionVacantError'; + } +} diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts new file mode 100644 index 0000000..fc395b8 --- /dev/null +++ b/apps/api/src/modules/process/index.ts @@ -0,0 +1,26 @@ +/** + * module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 + */ +export type { + ActivityContractFace, + Candidate, + ClosureInput, + ClosureReport, + ClosureViolation, + NodeInstance, + OwnerSpec, + ProcessPackage, + ResolveContext, + Resolution, + WorkflowState, +} from './contract.ts'; +export { NotPublishableError, PositionVacantError } from './contract.ts'; + +export { checkClosure, publishable } from './internal/domain/closure.ts'; +export { + aggregateWorkflowState, + FAILURE_DIGESTIONS, + isDigested, + nextInstanceNumber, + resolveOwner, +} from './internal/domain/owner-resolution.ts'; diff --git a/apps/api/src/modules/process/internal/domain/closure.ts b/apps/api/src/modules/process/internal/domain/closure.ts new file mode 100644 index 0000000..6553de2 --- /dev/null +++ b/apps/api/src/modules/process/internal/domain/closure.ts @@ -0,0 +1,218 @@ +/** + * 边映射闭合校验 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03)。 + * + * 「**连线即映射,画图即校验,不闭合不得发布。** + * 连一条边即须完成该边映射(必填未映射 = 边未完成),类型兼容依两端契约 schema 当场校验; + * 存在未完成边、未满足必填或类型冲突的流程包**可存草稿、不得发布**。 + * AND 汇合后的同名字段必须**显式指定来源分支**,不做隐式合并。」 + * + * 前端画布的即时校验与后端裁决**同源** —— 这一份实现两边共用。 + */ +import type { ClosureReport, ClosureViolation, Edge, ProcessNode, ProcessPackage } from '@contracts'; + +/** 活动契约的必填面 —— 由编排器从活动库取得 */ +export type ActivityContractFace = { + readonly activityId: string; + /** data 的必填字段路径 */ + readonly requiredInputs: readonly string[]; + /** 字段 → 类型(JSON Schema 的 type) */ + readonly inputTypes: Readonly>; + /** 输出口:口名 → 载荷字段类型 */ + readonly outputs: Readonly>>>; +}; + +export type ClosureInput = { + readonly pkg: ProcessPackage; + /** 已安装活动的契约面;缺失即 unknown-activity */ + readonly contracts: ReadonlyMap; + /** 已存在的岗位编码 */ + readonly positions: ReadonlySet; +}; + +export function checkClosure(input: ClosureInput): ClosureReport { + const { pkg, contracts, positions } = input; + const violations: ClosureViolation[] = []; + const byId = new Map(pkg.nodes.map((n) => [n.id, n])); + const incoming = new Map(); + const outgoing = new Map(); + + for (const edge of pkg.edges) { + (incoming.get(edge.to) ?? incoming.set(edge.to, []).get(edge.to)!).push(edge); + (outgoing.get(edge.from) ?? outgoing.set(edge.from, []).get(edge.from)!).push(edge); + if (!byId.has(edge.from) || !byId.has(edge.to)) { + violations.push({ kind: 'dangling-node', edgeId: edge.id, message: `边 ${edge.id} 指向不存在的节点` }); + } + } + + for (const node of pkg.nodes) { + // 孤立节点:除起点外都得有入边;除终点外都得有出边 + if (node.kind !== 'start' && (incoming.get(node.id)?.length ?? 0) === 0) { + violations.push({ kind: 'dangling-node', nodeId: node.id, message: `节点「${node.label}」无入边` }); + } + if (node.kind !== 'end' && (outgoing.get(node.id)?.length ?? 0) === 0) { + violations.push({ kind: 'dangling-node', nodeId: node.id, message: `节点「${node.label}」无出边` }); + } + + if (node.kind === 'activity') { + violations.push(...checkActivityNode(node, incoming.get(node.id) ?? [], contracts, positions)); + } + if (node.kind === 'approval' && !positions.has(node.approver.position)) { + violations.push({ + kind: 'missing-owner-position', + nodeId: node.id, + message: `审批点「${node.label}」的岗位不存在:${node.approver.position}`, + }); + } + if (node.kind === 'gateway') { + violations.push(...checkGateway(node, incoming.get(node.id) ?? [], outgoing.get(node.id) ?? [])); + } + } + + violations.push(...checkTolerateDependencies(pkg, contracts, byId)); + + return { publishable: violations.length === 0, violations }; +} + +function checkActivityNode( + node: Extract, + inbound: readonly Edge[], + contracts: ReadonlyMap, + positions: ReadonlySet, +): ClosureViolation[] { + const out: ClosureViolation[] = []; + const face = contracts.get(node.activityId); + + if (face === undefined) { + out.push({ + kind: 'unknown-activity', + nodeId: node.id, + message: `节点「${node.label}」引用的活动未安装:${node.activityId}`, + }); + return out; + } + + if (!positions.has(node.owner.position)) { + out.push({ + kind: 'missing-owner-position', + nodeId: node.id, + message: `节点「${node.label}」的岗位不存在:${node.owner.position}`, + }); + } + + // 连一条边即须完成该边映射 + 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}`, + }); + } + } + } + + // AND 汇合后的同名字段必须显式指定来源分支,不做隐式合并 + const targets = inbound.flatMap((e) => e.mapping.map((m) => m.target)); + const seen = new Set(); + for (const target of targets) { + if (seen.has(target) && inbound.length > 1) { + out.push({ + kind: 'join-ambiguous-source', + nodeId: node.id, + field: target, + message: `汇合处字段 ${target} 有多个来源分支,须显式指定 —— 不做隐式合并`, + }); + } + seen.add(target); + } + + return out; +} + +function checkGateway( + node: Extract, + inbound: readonly Edge[], + outbound: readonly Edge[], +): ClosureViolation[] { + const out: ClosureViolation[] = []; + if (node.mode === 'fork' && outbound.length < 2) { + out.push({ kind: 'gateway-unbalanced', nodeId: node.id, message: `分叉网关「${node.label}」出边少于两条` }); + } + if (node.mode === 'join' && inbound.length < 2) { + out.push({ kind: 'gateway-unbalanced', nodeId: node.id, message: `汇合网关「${node.label}」入边少于两条` }); + } + // XOR 的每条出边必须有条件(声明式、静态可检,不得内嵌自由逻辑) + if (node.gateway === 'exclusive' && node.mode === 'fork') { + for (const edge of outbound) { + if (edge.condition === undefined || edge.condition.trim() === '') { + out.push({ kind: 'edge-unmapped', edgeId: edge.id, nodeId: node.id, message: `异或网关的出边 ${edge.id} 缺条件` }); + } + } + } + return out; +} + +/** + * 容忍(仅限 AND 分支):静态校验其**下游映射不得依赖该分支的必填字段**(OS 03 失败走向)。 + */ +function checkTolerateDependencies( + pkg: ProcessPackage, + contracts: ReadonlyMap, + byId: ReadonlyMap, +): ClosureViolation[] { + const out: ClosureViolation[] = []; + const tolerated = new Set( + pkg.nodes.filter((n) => n.kind === 'activity' && n.failure.kind === 'tolerate').map((n) => n.id), + ); + if (tolerated.size === 0) return out; + + for (const edge of pkg.edges) { + if (!tolerated.has(edge.from)) continue; + const target = byId.get(edge.to); + 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)) { + out.push({ + kind: 'tolerate-required-dependency', + edgeId: edge.id, + nodeId: target.id, + field: mapping.target, + message: `容忍分支 ${edge.from} 的输出被下游必填字段 ${mapping.target} 依赖 —— 汇合处必填映射不得依赖可空分支`, + }); + } + } + } + return out; +} + +/** 不闭合不得发布 —— 发布门的唯一判据 */ +export function publishable(report: ClosureReport): boolean { + return report.publishable; +} diff --git a/apps/api/src/modules/process/internal/domain/owner-resolution.ts b/apps/api/src/modules/process/internal/domain/owner-resolution.ts new file mode 100644 index 0000000..175752b --- /dev/null +++ b/apps/api/src/modules/process/internal/domain/owner-resolution.ts @@ -0,0 +1,137 @@ +/** + * 负责人四策略解析与工作流状态聚合 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03 / 3.2)。 + */ +import type { OwnerSpec, TerminalState, WorkflowState } from '@contracts'; + +/* ── 负责人四策略(OS 03) ────────────────────────────────────────────── */ + +export type Candidate = { + readonly staffId: string; + readonly name: string; + /** 轮值用:岗内在途任务数 */ + readonly openTasks: number; +}; + +export type ResolveContext = { + readonly candidates: readonly Candidate[]; + /** 来源跟随:同一工作流内该岗位已解析出的人 */ + readonly alreadyResolved?: string | undefined; +}; + +export type Resolution = + | { readonly kind: 'resolved'; readonly staffId: string; readonly by: OwnerSpec['strategy']; readonly rationale: string } + /** 岗位空缺 —— 按节点的失败走向处置,或转治理台待指派 */ + | { readonly kind: 'vacant'; readonly position: string }; + +/** + * 「编排声明岗位,起单时**确定性解析**。策略封闭四选; + * 默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,**结果与依据留痕**。」 + */ +export function resolveOwner(spec: OwnerSpec, context: ResolveContext): Resolution { + const active = context.candidates; + + switch (spec.strategy) { + case 'assigned': { + const staffId = spec.staffId; + if (staffId === undefined || !active.some((c) => c.staffId === staffId)) { + return { kind: 'vacant', position: spec.position }; + } + return { kind: 'resolved', staffId, by: 'assigned', rationale: '编排直接绑定员工' }; + } + + case 'follow-source': { + // 来源跟随:复用同一工作流内该岗位已解析出的人,保持一单一人的连续 + const existing = context.alreadyResolved; + if (existing !== undefined && active.some((c) => c.staffId === existing)) { + return { kind: 'resolved', staffId: existing, by: 'follow-source', rationale: '同流内该岗位已解析出的人' }; + } + return fallbackToRotation(spec, active); + } + + case 'rotation': + return fallbackToRotation(spec, active); + + case 'sole': { + if (active.length === 0) return { kind: 'vacant', position: spec.position }; + if (active.length === 1) { + return { kind: 'resolved', staffId: active[0]!.staffId, by: 'sole', rationale: '岗位唯一人' }; + } + // 岗上多人时按轮值 —— 默认口径 + return fallbackToRotation(spec, active); + } + } +} + +/** 轮值:岗内以**在途任务数最少者**优先;并列时按 staffId 稳定排序,保证确定性 */ +function fallbackToRotation(spec: OwnerSpec, candidates: readonly Candidate[]): Resolution { + if (candidates.length === 0) return { kind: 'vacant', position: spec.position }; + const picked = [...candidates].sort( + (a, b) => a.openTasks - b.openTasks || (a.staffId < b.staffId ? -1 : a.staffId > b.staffId ? 1 : 0), + )[0]!; + return { + kind: 'resolved', + staffId: picked.staffId, + by: 'rotation', + rationale: `轮值:在途任务数 ${picked.openTasks} 最少`, + }; +} + +/* ── 工作流状态聚合(OS 3.2) ────────────────────────────────────────── */ + +/** + * 「工作流状态是**聚合投影** —— 由流内各任务的状态按固定规则聚合推导, + * 流程层**不另设状态机**。计量对象是**节点实例**(重开产生新实例,旧实例已终,不再入算)。」 + */ +export type NodeInstance = { + readonly nodeId: string; + readonly instance: number; + readonly running: boolean; + /** 待人 / 监听中 */ + readonly waiting: boolean; + readonly terminal?: TerminalState; + /** 非完成终态的失败走向是否已消化(改道 / 容忍 / 升审批一经生效即为消化) */ + readonly failureDigested?: boolean; + /** XOR 未选分支从未激活 —— 不入集合 */ + readonly activated: boolean; +}; + +export function aggregateWorkflowState( + instances: readonly NodeInstance[], + controlTerminated: boolean, +): WorkflowState { + if (controlTerminated) return 'terminated'; + + const live = instances.filter((i) => i.activated); + + // 任一实例以非完成终态结束且其失败走向未消化 → 异常 + const unresolvedFailure = live.some( + (i) => i.terminal !== undefined && i.terminal !== 'released' && i.failureDigested !== true, + ); + if (unresolvedFailure) return 'abnormal'; + + if (live.some((i) => i.running)) return 'running'; + if (live.some((i) => i.waiting)) return 'waiting'; + + // 全部已激活的节点实例收敛(完成或已消化)且无在途实例 → 已完成 + const allConverged = live.every( + (i) => i.terminal === 'released' || (i.terminal !== undefined && i.failureDigested === true), + ); + return allConverged && live.length > 0 ? 'completed' : 'waiting'; +} + +/* ── 失败走向的消化判定(OS 03) ────────────────────────────────────── */ + +export const FAILURE_DIGESTIONS = ['reroute', 'tolerate', 'escalate'] as const; + +/** 改道、容忍与升审批一经生效即为**消化**,不入异常;终止流待人工处置中仍算异常 */ +export function isDigested(disposition: 'terminate' | 'reroute' | 'escalate' | 'tolerate'): boolean { + return disposition !== 'terminate'; +} + +/** + * 「流程层**不自动重试**活动(过程内重试归 Harness); + * 流级重做一律是人裁决后**重开新任务实例** —— 同一节点、新任务 ID、留痕关联,不复活旧任务。」 + */ +export function nextInstanceNumber(instances: readonly NodeInstance[], nodeId: string): number { + return instances.filter((i) => i.nodeId === nodeId).reduce((max, i) => Math.max(max, i.instance), 0) + 1; +} diff --git a/apps/api/src/modules/process/package.json b/apps/api/src/modules/process/package.json new file mode 100644 index 0000000..56d3935 --- /dev/null +++ b/apps/api/src/modules/process/package.json @@ -0,0 +1 @@ +{ "name": "@modules/process", "version": "0.0.0", "private": true, "type": "module", "exports": { ".": "./index.ts" } } diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 11167b6..ccf5826 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -12,9 +12,11 @@ | M2 | `memory`、`toolbox`、`channel`、`message` | ✅ 完成 | | M3 | `interaction`、`human-tool`、`release`、`activity-registry`、`@sfeel` | ✅ 完成 | | M4 | `skill-engine`、`loop-engine`、`flow-engine`、`task` | ✅ 完成(模块与 domain;app 装配随 M5) | -| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | ⬜ 未开始 | +| M5 | `process`、`trigger`、`@activity-sdk` + 首个样例活动 | 🟡 `process` 的 domain 已落 | | M6 | 前端:工作台五要素 → 治理台九功能;`minute`、`report` | ⬜ 未开始 | +**当前:325 个测试通过(含 1 组对真实 PG 的集成测试),门禁五道全绿。** + ## 常用命令 ```bash @@ -60,10 +62,29 @@ Enabled ≠ Active;前提求值与语义性前提交裁量;DMN 确定性求值 `task`(终态五名裁定 / Harness 预算与停滞熔断 / 控制三语义 / 改派 / 断点续跑三份持久状态 / 封卷触发蒸馏)。 +**M5(进行中)** — `process` 的 domain:边映射闭合校验(七类违规)、负责人四策略确定性解析、 +工作流状态聚合投影、失败走向消化判定、节点实例重开。 + +## 下一步(按序) + +1. **M5 · `process` 余下**:`port.ts` + usecase(流程包存取与发布、工作流实例起单与流转)、 + `internal/adapter/workflow-compiler.ts`(Mastra `createWorkflow`/`createStep`,编码规范 §6.1: + XOR → `.branch()`,AND → `.parallel()`,审批点与活动节点 `suspend()`,终态事件到达 `resume()`)。 +2. **M5 · `trigger`**:三类触发器(周期两型 / 事件 / 登记)、判定与经调用接口起单; + 接上 `container.ts` 里的 `TriggerMatcher` 占位;pg-boss 承接周期规则与监听时限扫描。 +3. **M5 · `@activity-sdk`**:基座六件接口 + `defineActivity()` + 本地开发宿主 + 回放夹具 + + tsup 编译预设(§8.2 / ADR-004);随后在 `activities/` 下按《MVP示例工作流》做首个活动 + (建议先 A1 受理归一 = flow,再 A3 渠道寻源 = loop)。 +4. **M4 收尾**:把 loop / flow / skill / task 接入 `container.ts` + —— 需要 `loop-engine/internal/adapter/agent-runner.ts` 的 Mastra Agent(§6.1)。 +5. **M6 · 前端**:按 README《前端 UI · 原型 1:1 平移》逐屏平移 + `~/coding/JCAI_PRD/workbench`(约 8k 行)与 `~/coding/JCAI_PRD/governance`(约 5k 行); + CSS 与 DOM 原样搬,组件按 §9 重排,mock 数据换真实 API;`minute`、`report` 两模块随之。 +6. **验收**:README《MVP 完成判据》十条场景端到端跑通。 + ## 待接的占位(详见 docs/debt.md) - `container.ts` 两个占位端口:`InFlightWorkProbe`(M4)、`TriggerMatcher`(M5) -- 条件求值 `passthroughEvaluator`(M3 接 `@sfeel`) - 派发器的消费者表为空(M4 任务 / M5 触发器) - pg-boss 已装未用(M5 定时作业) - M4 四模块尚未接入 `container.ts`(需要 Mastra Agent adapter 与流程层起单,随 M5)