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 4ccac1a..5896118 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, NotPublishableError, PositionVacantError } 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 { assemble, dispatchOf, joinReady, rejectRouteOf, selectEdges, startNodeOf, } from './internal/domain/routing.ts'; +export { advanceOrder, assemble, dispatchOf, joinReady, rejectRouteOf, selectEdges, startNodeOf, } from './internal/domain/routing.ts'; export { checkClosure, publishable } 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/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index fc36dcd..a5b1393 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -21,6 +21,7 @@ import { workOrderReplySchema, } from '@contracts'; import { ReplyRejectedError } from '@modules/human-tool'; +import { advanceOrder } from '@modules/process'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; @@ -76,23 +77,11 @@ export function registerDeskRoutes(app: Hono, container: Container): voi const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); /* - * 链的呈现顺序:**按流程列位排,同列按节点编号排** —— 不吃 `nodes` 数组的书写顺序。 - * - * 并行分支(如 G2 分出的 A2 / A3)在图上本无先后,链却必须挑一个顺序。 - * 挑「作者数组里怎么写」是最不稳的:换个人写包,链的读法就变了。 - * 列位 `layout.col` 是流程的推进次序(拓扑的可视化),同列的并行支再按编号定 —— - * 于是链永远读作 A1 → A2 → A3 → A4,与人对活动编号的预期一致。 - * `layout` 声明为「不影响语义」,这里也只拿它排**呈现**,不参与任何流转判断。 + * 链的呈现顺序:**按边的拓扑序**(`advanceOrder`),不吃 `nodes` 数组的书写顺序, + * 也不看图纸坐标 —— 图纸一回形走线,列位就跟推进次序对不上了。 */ - const declared = (pkg?.nodes ?? []).filter((n) => n.kind === 'activity' || n.kind === 'approval'); - const nodes = declared - .map((n, index) => ({ n, index })) - .sort( - (a, b) => - (a.n.layout?.col ?? a.index) - (b.n.layout?.col ?? b.index) || - (a.n.id < b.n.id ? -1 : a.n.id > b.n.id ? 1 : 0), - ) - .map((x) => x.n); + const nodes = + pkg === undefined ? [] : advanceOrder(pkg).filter((n) => n.kind === 'activity' || n.kind === 'approval'); /* * 状态取**真实的节点实例**,不按下标推。 diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 61ed516..352146f 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -15,7 +15,9 @@ - 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState` (`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011) `isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady` - `rejectRouteOf` `dispatchOf` `startNodeOf` + `rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder` + (`advanceOrder` = 推进次序,按边拓扑排、排除回边、并行支按编号定序; + 呈现用,不参与流转判断 —— 图纸坐标不配决定次序) - adapter:`createProcessStore`;Drizzle 表:`processPackageTable` `workflowTable` `nodeInstanceTable` 层级: L7(编码规范 §4) diff --git a/apps/api/src/modules/process/__tests__/advance-order.test.ts b/apps/api/src/modules/process/__tests__/advance-order.test.ts new file mode 100644 index 0000000..582c8f2 --- /dev/null +++ b/apps/api/src/modules/process/__tests__/advance-order.test.ts @@ -0,0 +1,170 @@ +/** + * 推进次序 —— 拓扑序,不看图纸(domain 测试零 mock)。 + * + * 这条曾经拿 `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'; +import { advanceOrder } from '../internal/domain/routing.ts'; + +/** 活动节点:`col`/`row` 故意按回形图给,证明次序不吃图纸 */ +const act = (id: string, col: number, row: number): ProcessNode => + ({ + id, + kind: 'activity', + label: id, + activityId: id.toLowerCase(), + owner: { position: 'BD', strategy: 'sole' }, + failure: { kind: 'terminate' }, + 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; + +const pkg = (nodes: ProcessNode[], edges: Edge[]): ProcessPackage => + ({ + id: 'unified-quote' as never, + name: '统一报价', + version: 1, + state: 'published', + callInterface: { input: {}, output: {} }, + nodes, + edges, + variables: [], + }) as never; + +/** 断言里要按裸字符串找位置,所以出口就摘掉品牌 —— 测试比对的是「读出来的次序」 */ +const ids = (p: ProcessPackage): string[] => advanceOrder(p).map((n) => n.id); +const acts = (p: ProcessPackage): string[] => + advanceOrder(p) + .filter((n) => n.kind === 'activity') + .map((n) => n.id); + +describe('advanceOrder', () => { + /** MVP 图原样:col 是蛇形列位 —— A7(col 1)与 A1(col 1)并列,A6(col 3)与 A2(col 3)并列 */ + 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('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('A4', 'A5'), + edge('A5', 'A6'), + edge('A6', 'G4'), + edge('G4', 'end_won'), + edge('G4', 'A4', true), // 回边:有异议退回重新定价 + edge('G4', 'A7'), + edge('A7', 'end_arch'), + ], + ); + + it('回形图也读作编号序 —— 列位并列不影响推进次序', () => { + expect(acts(serpentine)).toEqual(['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7']); + }); + + it('起点在最前、网关按边落位、两个终点都在 A7 之后', () => { + 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')); + }); + + /** + * 回边不参与排序。要看出差别,编号序得跟拓扑序**相反** —— + * AZ 在流程上先于 AB,但字母序 AB 在前。回边一旦当成正向边,两头互等成环、 + * 一起落榜,就只能按编号读作 AB → AZ,把流程读反了。 + */ + it('回边排除在外 —— 否则两头互等成环,链会读反', () => { + const nodes = [ + { id: 'start', kind: 'start', label: '起', layout: { row: 0, col: 0 } } as never, + act('AZ', 1, 0), + act('AB', 2, 0), + { id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 3 } } as never, + ]; + const forward = [edge('start', 'AZ'), edge('AZ', 'AB'), edge('AB', 'end')]; + + expect(acts(pkg(nodes, [...forward, edge('AB', 'AZ', true)]))).toEqual(['AZ', 'AB']); + // 同一张图,只把回边的声明摘掉 —— 立刻读反 + expect(acts(pkg(nodes, [...forward, edge('AB', 'AZ')]))).toEqual(['AB', 'AZ']); + }); + + it('真并行支按编号定序 —— 同一个包在谁手上都读作同一条链', () => { + const parallel = pkg( + [ + { id: 'start', kind: 'start', label: '起', layout: { row: 0, col: 0 } } as never, + gw('F', 'fork', 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, + ], + [ + edge('start', 'F'), + edge('F', 'AZ'), + edge('F', 'AB'), + edge('AZ', 'J'), + edge('AB', 'J'), + edge('J', 'end'), + ], + ); + // 书写顺序是 AZ 在前,但编号 AB < AZ —— 链按编号读 + expect(acts(parallel)).toEqual(['AB', 'AZ']); + }); + + it('不可达节点不丢 —— 按编号缀在末尾', () => { + const orphan = pkg( + [ + { id: 'start', kind: 'start', label: '起', layout: { row: 0, col: 0 } } as never, + act('A1', 1, 0), + act('A9', 9, 9), // 没有任何边连它 + { id: 'end', kind: 'end', label: '终', layout: { row: 0, col: 2 } } as never, + ], + [edge('start', 'A1'), edge('A1', 'end')], + ); + // 无入边的节点本身就在第一轮 ready 里,按编号与 start 一同排;关键是它在 + const order = ids(orphan); + expect(order).toContain('A9'); + expect(order).toHaveLength(4); + }); + + it('空包不炸', () => { + expect(advanceOrder(pkg([], []))).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index 62eb2e5..b3161e3 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -37,6 +37,7 @@ export type { ProcessServiceDeps } from './internal/usecase/process-service.ts'; export { createProcessService } from './internal/usecase/process-service.ts'; export { + advanceOrder, assemble, dispatchOf, joinReady, diff --git a/apps/api/src/modules/process/internal/domain/routing.ts b/apps/api/src/modules/process/internal/domain/routing.ts index 6411f53..2c26491 100644 --- a/apps/api/src/modules/process/internal/domain/routing.ts +++ b/apps/api/src/modules/process/internal/domain/routing.ts @@ -141,3 +141,52 @@ export function rejectRouteOf( export function startNodeOf(pkg: ProcessPackage): ProcessNode | undefined { return pkg.nodes.find((n) => n.kind === 'start'); } + +/** + * 推进次序 —— 按**边**做拓扑序,不看图纸坐标。 + * + * 「链」要回答的是「这一单接下来往哪走」,那是边的事实。曾经拿 `layout.col` 排, + * 因为 v1 的包是一行从左画到右,列位恰好等于推进次序 —— 但 MVP 图是**回形走线** + * (第 0 行左→右、第 1 行右→左、第 2 行折回最左),A7 的列位跟 A1 并列,链就读成了 + * A1 → A7 → A2 → A6 → A3(实测)。图纸声明为「不影响语义」,那它也不配决定次序。 + * + * 回边(`reopen`)排除在外:它是**重新入场**,不是往前推进。留着它图上就有环, + * 拓扑序无从下手(G4 → A4 会让 A4 等自己的下游)。 + * + * 真并行的分支(G2 分出的 A2 / A3)在图上本无先后,链却必须挑一个顺序: + * 按**节点编号**定,于是同样的包在谁手上都读作同一条链。 + * 进不了拓扑序的节点(不可达、或排除回边后仍有环)一律按编号缀在末尾 —— 少排序,不丢节点。 + */ +export function advanceOrder(pkg: ProcessPackage): readonly ProcessNode[] { + const byId = new Map(pkg.nodes.map((n) => [n.id, n])); + const byNodeId = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); + + const forward = pkg.edges.filter((e) => e.reopen !== true && byId.has(e.from) && byId.has(e.to)); + const indegree = new Map(pkg.nodes.map((n) => [n.id, 0])); + const next = new Map(); + for (const e of forward) { + indegree.set(e.to, (indegree.get(e.to) ?? 0) + 1); + next.set(e.from, [...(next.get(e.from) ?? []), e.to]); + } + + // Kahn:每轮从「无未满足前驱」的节点里取编号最小的,顺序才与书写无关 + const ready = pkg.nodes.filter((n) => indegree.get(n.id) === 0).map((n) => n.id); + const ordered: ProcessNode[] = []; + const placed = new Set(); + while (ready.length > 0) { + ready.sort(byNodeId); + const id = ready.shift()!; + if (placed.has(id)) continue; + placed.add(id); + const node = byId.get(id); + if (node !== undefined) ordered.push(node); + for (const to of next.get(id) ?? []) { + const left = (indegree.get(to) ?? 0) - 1; + indegree.set(to, left); + if (left === 0) ready.push(to); + } + } + + const stranded = pkg.nodes.filter((n) => !placed.has(n.id)).sort((a, b) => byNodeId(a.id, b.id)); + return [...ordered, ...stranded]; +} diff --git a/eslint.config.js b/eslint.config.js index 0b27cc4..c0f962b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,6 +16,9 @@ export default tseslint.config( '**/.build/**', '**/node_modules/**', 'api-report/**', + // api:check 生成声明用的临时盘;它和 api-report 一样是**产物不是源码**。 + // 不排除的话,api:check 中途失败留下的半成品会让 lint 报上千条假错。 + '.api-tmp/**', 'tools/depcruise-tiers.generated.cjs', 'docs/**', ], diff --git a/tools/api-report.mjs b/tools/api-report.mjs index 70aba7d..6d19f41 100644 --- a/tools/api-report.mjs +++ b/tools/api-report.mjs @@ -38,6 +38,12 @@ try { } catch (err) { process.stderr.write((err.stdout ?? '') + (err.stderr ?? '')); process.stderr.write('\napi:check —— 声明生成失败,先修 tsc。\n'); + /* + * 失败也要清干净。半成品留在盘上会**污染下一道闸**:`.api-tmp` 里是生成的 .d.ts, + * eslint 扫到它会报上千条 `consistent-type-imports` —— 于是 tsc 一红,lint 跟着红, + * 人以为自己又写坏了别的地方。实测踩过一次。 + */ + rmSync(tmp, { recursive: true, force: true }); process.exit(1); }