diff --git a/api-report/apps/api/src/modules/process/contract.d.ts b/api-report/apps/api/src/modules/process/contract.d.ts index 9759748..2d45208 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -27,13 +27,15 @@ export type StartWorkflowInput = { readonly data: Record; }; export type ProcessService = { + /** + * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 + * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 + */ savePackage(pkg: ProcessPackage): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; - /** 画图即校验 —— 与前端画布同源 */ + /** 画图即校验 —— 与前端画布同源;只算不存 */ validate(pkg: ProcessPackage): Promise; - /** 不闭合不得发布 */ - publish(id: string, version: number): Promise; start(input: StartWorkflowInput): Promise; getWorkflow(id: string): Promise; listWorkflows(limit?: number): Promise; @@ -55,7 +57,8 @@ export declare class CallInterfaceViolationError extends Error { readonly errors: readonly string[]; constructor(packageId: string, errors: readonly string[]); } -export declare class NotPublishableError extends Error { +/** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */ +export declare class NotClosedError extends Error { readonly report: ClosureReport; constructor(report: ClosureReport); } 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 5896118..0c0fb53 100644 --- a/api-report/apps/api/src/modules/process/index.d.ts +++ b/api-report/apps/api/src/modules/process/index.d.ts @@ -2,12 +2,12 @@ * module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ export type { ActivityContractFace, AssembleScope, Candidate, ClosureInput, ClosureReport, ClosureViolation, NodeInstance, OwnerSpec, ProcessPackage, ProcessService, ResolveContext, Resolution, StartWorkflowInput, WorkflowRecord, WorkflowState, } from './contract.ts'; -export { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from './contract.ts'; +export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts'; export type { ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts'; export type { ProcessServiceDeps } from './internal/usecase/process-service.ts'; export { createProcessService } from './internal/usecase/process-service.ts'; export { advanceOrder, assemble, dispatchOf, joinReady, rejectRouteOf, selectEdges, startNodeOf, } from './internal/domain/routing.ts'; -export { checkClosure, publishable } from './internal/domain/closure.ts'; +export { checkClosure, closed } from './internal/domain/closure.ts'; export { aggregateWorkflowState, FAILURE_DIGESTIONS, isDigested, nextInstanceNumber, resolveOwner, } from './internal/domain/owner-resolution.ts'; export { createProcessStore } from './internal/adapter/process-store.ts'; export { nodeInstanceTable, processPackageTable, workflowTable } from './internal/adapter/process-schema.ts'; diff --git a/api-report/apps/api/src/modules/process/port.d.ts b/api-report/apps/api/src/modules/process/port.d.ts index 1e62ab4..201d28a 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -39,7 +39,6 @@ export type ProcessStore = { savePackage(pkg: ProcessPackage): Promise; getPackage(id: string, version?: number): Promise; listPackages(): Promise; - publishPackage(id: string, version: number): Promise; insertWorkflow(record: WorkflowRecord): Promise; getWorkflow(id: string): Promise; listWorkflows(limit: number): Promise; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 792e197..c7fab63 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -462,8 +462,7 @@ export declare const listProcessPackagesRoute: { name: z.ZodString; version: z.ZodNumber; state: z.ZodEnum<{ - draft: "draft"; - published: "published"; + active: "active"; retired: "retired"; }>; callInterface: z.ZodObject<{ @@ -650,12 +649,13 @@ export declare const listProcessPackagesRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; -/** 画图即校验 —— 与前端画布同源的闭合校验 */ /** - * 保存流程包(编排工作区的落笔)。 - * **保存 ≠ 发布**:落的是这一版的定义(草稿可反复存);已发布版本不可回改 —— - * 后端按 (id, version, state) 判:published 版拒存,提示另起新版本。 - * 返回连同闭合报告:画图即校验,每次落笔都知道离「可发布」还差几条。 + * 保存流程包 —— **严格检查 + 保存即生效**,没有发布这一道门。 + * + * 不闭合即拒存(412 带闭合报告):存下来的就是能起单的。 + * 保存**恒产生新版本号**,由服务端算 —— body 里的 `version` 只用于「改的是哪一版」的溯源, + * 服务端不采信它当目标版本号。在途工作流按 (包id, 版本) 读定义,就地覆盖会改掉它们读的东西。 + * 返回的 `pkg.version` 是**实际落的新版本**,`report` 是这一版的闭合报告(必然 closed)。 */ export declare const saveProcessRoute: { readonly method: "PUT"; @@ -666,8 +666,7 @@ export declare const saveProcessRoute: { name: z.ZodString; version: z.ZodNumber; state: z.ZodEnum<{ - draft: "draft"; - published: "published"; + active: "active"; retired: "retired"; }>; callInterface: z.ZodObject<{ @@ -859,8 +858,7 @@ export declare const saveProcessRoute: { name: z.ZodString; version: z.ZodNumber; state: z.ZodEnum<{ - draft: "draft"; - published: "published"; + active: "active"; retired: "retired"; }>; callInterface: z.ZodObject<{ @@ -1046,7 +1044,7 @@ export declare const saveProcessRoute: { diagram: z.ZodOptional>; }, z.core.$strip>; report: z.ZodObject<{ - publishable: z.ZodBoolean; + closed: z.ZodBoolean; violations: z.ZodArray; }, z.core.$strip>; }; +/** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */ export declare const validateProcessRoute: { readonly method: "POST"; readonly path: "/api/gov/process/validate"; @@ -1076,8 +1075,7 @@ export declare const validateProcessRoute: { name: z.ZodString; version: z.ZodNumber; state: z.ZodEnum<{ - draft: "draft"; - published: "published"; + active: "active"; retired: "retired"; }>; callInterface: z.ZodObject<{ @@ -1264,35 +1262,7 @@ export declare const validateProcessRoute: { }, z.core.$strip>; }, z.core.$strip>; readonly output: z.ZodObject<{ - publishable: z.ZodBoolean; - violations: z.ZodArray; - nodeId: z.ZodOptional>; - edgeId: z.ZodOptional; - field: z.ZodOptional; - message: z.ZodString; - }, z.core.$strip>>; - }, z.core.$strip>; -}; -export declare const publishProcessRoute: { - readonly method: "POST"; - readonly path: "/api/gov/process/:id/:version/publish"; - readonly params: z.ZodObject<{ - id: z.ZodString; - version: z.ZodString; - }, z.core.$strip>; - readonly output: z.ZodObject<{ - publishable: z.ZodBoolean; + closed: z.ZodBoolean; violations: z.ZodArray; callInterface: z.ZodObject<{ diff --git a/api-report/packages/contracts/src/process-package.d.ts b/api-report/packages/contracts/src/process-package.d.ts index f12b28c..dcc5feb 100644 --- a/api-report/packages/contracts/src/process-package.d.ts +++ b/api-report/packages/contracts/src/process-package.d.ts @@ -280,10 +280,20 @@ export declare const callInterfaceSchema: z.ZodObject<{ output: z.ZodRecord; }, z.core.$strip>; export type CallInterface = z.infer; -export declare const PROCESS_PACKAGE_STATES: readonly ["draft", "published", "retired"]; +/** + * 流程包状态 —— **没有草稿态,也没有发布态**。 + * + * 「严格检查 + 保存生效」:保存时跑闭合校验,不闭合直接拒存;存下来的就是生效的。 + * 生效与否由**运行时**决定 —— 新工作流起单时取最新版,在途的按启动时快照的版本跑完。 + * 所以不需要「已发布」这个标记:能存进来的版本都能起单。 + * + * 保存**恒产生新版本号**(服务端算):工作流按 (包id, 版本) 读定义, + * 就地覆盖会改掉在途任务正在读的东西 —— 那正是「旧任务走完旧版本」要防的。 + * `retired` 留给「这一版不再可起单」,起单时拒。 + */ +export declare const PROCESS_PACKAGE_STATES: readonly ["active", "retired"]; export declare const processPackageStateSchema: z.ZodEnum<{ - draft: "draft"; - published: "published"; + active: "active"; retired: "retired"; }>; export type ProcessPackageState = z.infer; @@ -292,8 +302,7 @@ export declare const processPackageSchema: z.ZodObject<{ name: z.ZodString; version: z.ZodNumber; state: z.ZodEnum<{ - draft: "draft"; - published: "published"; + active: "active"; retired: "retired"; }>; callInterface: z.ZodObject<{ @@ -511,7 +520,7 @@ export declare const closureViolationSchema: z.ZodObject<{ }, z.core.$strip>; export type ClosureViolation = z.infer; export declare const closureReportSchema: z.ZodObject<{ - publishable: z.ZodBoolean; + closed: z.ZodBoolean; violations: z.ZodArray, container: Contai const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; return c.json(body, 400); } - const incoming = parsed.data.pkg; - const existing = await container.process.getPackage(incoming.id, incoming.version); - if (existing !== undefined && existing.state === 'published') { - const body: ApiError = { - code: 'precondition-failed', - message: `v${incoming.version} 已发布,不可回改 —— 另存为 v${incoming.version + 1} 再编排`, - }; - return c.json(body, 412); + /* + * **严格检查 + 保存即生效**:不闭合拒存(412 带报告),存下来的就能起单。 + * 版本号由用例层算(恒新增),这里不采信入参的 version —— 它只说明「改的是哪一版」。 + */ + try { + const pkg = await container.process.savePackage(parsed.data.pkg); + return c.json({ pkg, report: await container.process.validate(pkg) }); + } catch (error) { + if (error instanceof NotClosedError) { + const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report }; + return c.json(body, 412); + } + throw error; } - // 保存的永远是草稿:发布是另一道门(闭合校验),不在保存里顺带 - const pkg = await container.process.savePackage({ ...incoming, state: 'draft' }); - const report = await container.process.validate(pkg); - return c.json({ pkg, report }); }); /** 画图即校验 —— 与前端画布同源 */ @@ -302,21 +302,6 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai return c.json(await container.process.validate(parsed.data.pkg)); }); - /** 不闭合不得发布 */ - app.post(publishProcessRoute.path, async (c) => { - const denied = await governProcess(c.get('account')); - if (denied) return c.json(denied, denyStatus(denied)); - try { - return c.json(await container.process.publish(c.req.param('id'), Number(c.req.param('version')))); - } catch (error) { - if (error instanceof NotPublishableError) { - const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report }; - return c.json(body, 412); - } - throw error; - } - }); - /* ── 触发器配置 ──────────────────────────────────────────────────── */ app.get(listTriggersRoute.path, async (c) => { diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index 06a559a..37f5d41 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -83,7 +83,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({ id: 'unified-quote', name: '统一报价', version: 1, - state: 'draft', + state: 'active', callInterface: { input: { type: 'object', @@ -426,13 +426,40 @@ const TRIGGER_REGISTRATION = triggerConfigSchema.parse({ export async function seedMvpFlow(container: Container): Promise { const log = (line: string) => process.stdout.write(line + '\n'); - await container.process.savePackage(MVP_PACKAGE); - const report = await container.process.validate(MVP_PACKAGE); - if (!report.publishable) { - log(' 闭合校验未过,不得发布:' + report.violations.map((v) => v.message).join('; ')); + /* + * 保存即生效,且**内容没变就不再存新版**。 + * + * 保存恒新增版本(这是「旧任务走完旧版本」的前提),所以种子不能无条件存 —— + * 那样每跑一次播种就多一个版本,库里堆一串一模一样的定义。 + * 比对的是语义与呈现,不含 version/state(它们由保存本身决定)。 + */ + /* + * 比对要**认内容不认键序**。jsonb 从 PG 读回来时键是重排过的 + * (Postgres 对 jsonb 的对象键排序),直接 `JSON.stringify` 永远判不等 —— + * 于是每播一次种就多存一版一模一样的定义(实测:一口气堆到 v3)。 + * 所以递归按键名排序后再序列化;version / state 由保存本身决定,不参与比对。 + */ + const canonical = (value: unknown): unknown => + Array.isArray(value) + ? value.map(canonical) + : value !== null && typeof value === 'object' + ? Object.fromEntries( + Object.entries(value as Record) + .filter(([k]) => k !== 'version' && k !== 'state') + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([k, v]) => [k, canonical(v)]), + ) + : value; + const stripped = (pkg: unknown) => JSON.stringify(canonical(pkg)); + const current = await container.process.getPackage(MVP_PACKAGE.id); + if (current !== undefined && stripped(current) === stripped(MVP_PACKAGE)) { + log(`④ 流程包「统一报价」v${current.version} 已在库,内容未变 —— 不存新版`); } else { - await container.process.publish(MVP_PACKAGE.id, MVP_PACKAGE.version); - log(`④ 流程包「统一报价」v${MVP_PACKAGE.version} 已发布 —— ${MVP_PACKAGE.nodes.length} 节点,边映射闭合校验通过`); + const saved = await container.process.savePackage(MVP_PACKAGE); + log( + `④ 流程包「统一报价」v${saved.version} 已保存生效 —— ${saved.nodes.length} 节点,` + + `闭合校验通过(不闭合会拒存)${current === undefined ? '' : `;旧版 v${current.version} 原样留着,在途任务照它跑完`}`, + ); } await container.trigger.upsert(TRIGGER_EVENT); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index 3082fb6..98f0af6 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -739,10 +739,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { process: { start: async (input) => process.start(input), requiredInputs: async (packageId) => { - // 与起单取版同口径:读**最新已发布**的调用接口 —— 草稿的 schema 不该影响装配判定 + // 与起单取版同口径:读**最新一版**的调用接口(没有草稿了,存进来的都生效) const all = await process.listPackages(); const pkg = all - .filter((x) => x.id === packageId && x.state === 'published') + .filter((x) => x.id === packageId && x.state !== 'retired') .sort((a, b) => b.version - a.version)[0]; const input = pkg?.callInterface.input as { required?: string[] } | undefined; return input?.required ?? []; diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index d51e0e1..13f8878 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) @@ -8,11 +8,11 @@ 公开出口: index.ts - 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation` `ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch` - - 错误:`NotPublishableError` `PositionVacantError` + - 错误:`NotClosedError` `PositionVacantError` - 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway` `Sfeel` `FlowEventPublisher` `ReceiptNotifier` - 用例工厂:`createProcessService(deps): ProcessService` - - 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState` + - 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState` (`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011) `isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady` `rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder` @@ -25,8 +25,13 @@ 被依赖: trigger, app(治理台 · 工作流管理) 不变量(OS 03 / 3.2): - - **不闭合不得发布**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、 - 汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `publishable = false` + - **不闭合不得保存**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、 + 汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `closed = false`,`savePackage` 抛 `NotClosedError` + - **严格检查 + 保存即生效,没有发布机制**(产品决策 2026-07-28): + 没有草稿态、没有发布门。存进来的每一版都能起单;生效与否由**运行时**决定 —— + 新工作流起单取最新版,在途的按启动时快照的版本号读定义、跑完旧编排。 + - **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义, + 就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。 - **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure` - **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕 - **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**, diff --git a/apps/api/src/modules/process/__tests__/advance-order.test.ts b/apps/api/src/modules/process/__tests__/advance-order.test.ts index 582c8f2..d5ac520 100644 --- a/apps/api/src/modules/process/__tests__/advance-order.test.ts +++ b/apps/api/src/modules/process/__tests__/advance-order.test.ts @@ -39,7 +39,7 @@ const pkg = (nodes: ProcessNode[], edges: Edge[]): ProcessPackage => id: 'unified-quote' as never, name: '统一报价', version: 1, - state: 'published', + state: 'active', callInterface: { input: {}, output: {} }, nodes, edges, diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index 523624a..7973354 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -27,7 +27,7 @@ const pkg = (over: Partial = {}): ProcessPackage => id: 'quote' as never, name: '统一报价', version: 1, - state: 'draft', + state: 'active', callInterface: { input: {}, output: {} }, nodes: [ { id: 'start' as never, kind: 'start', label: '起点' }, @@ -61,11 +61,11 @@ const pkg = (over: Partial = {}): ProcessPackage => const contracts = new Map([['a4', face()]]); const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']); -describe('checkClosure —— 不闭合不得发布', () => { - it('必填全映射、岗位存在即可发布', () => { +describe('checkClosure —— 不闭合不得保存', () => { + it('必填全映射、岗位存在即可保存', () => { const report = checkClosure({ pkg: pkg(), contracts, positions }); expect(report.violations).toEqual([]); - expect(report.publishable).toBe(true); + expect(report.closed).toBe(true); }); it('边未完成映射即拦', () => { @@ -76,7 +76,7 @@ describe('checkClosure —— 不闭合不得发布', () => { ], }); const report = checkClosure({ pkg: broken, contracts, positions }); - expect(report.publishable).toBe(false); + 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'); }); @@ -320,7 +320,7 @@ describe('joinReady —— 网关直通道不参与等待', () => { id: 'p-join', name: '汇合测试', version: 1, - state: 'published', + state: 'active', callInterface: { input: { type: 'object' }, output: { type: 'object' } }, nodes: [ { kind: 'start', id: 'S', label: '开始' }, diff --git a/apps/api/src/modules/process/__tests__/start-guard.test.ts b/apps/api/src/modules/process/__tests__/start-guard.test.ts index 80103f7..1016f41 100644 --- a/apps/api/src/modules/process/__tests__/start-guard.test.ts +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -18,7 +18,7 @@ const PKG: ProcessPackage = processPackageSchema.parse({ id: 'p1', name: '测试包', version: 1, - state: 'published', + state: 'active', callInterface: { input: { type: 'object', required: ['customerCode'], properties: { customerCode: { type: 'string' } } }, output: { type: 'object' }, @@ -47,7 +47,6 @@ function harness(validationErrors: readonly string[]): Harness { savePackage: async () => undefined, getPackage: async () => PKG, listPackages: async () => [PKG], - publishPackage: async () => undefined, insertWorkflow: async (record) => { inserted.push(record); }, diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index f03dcc7..9091f5c 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -52,13 +52,15 @@ export type StartWorkflowInput = { }; export type ProcessService = { + /** + * 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。 + * 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。 + */ savePackage(pkg: ProcessPackage): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; - /** 画图即校验 —— 与前端画布同源 */ + /** 画图即校验 —— 与前端画布同源;只算不存 */ validate(pkg: ProcessPackage): Promise; - /** 不闭合不得发布 */ - publish(id: string, version: number): Promise; start(input: StartWorkflowInput): Promise; getWorkflow(id: string): Promise; @@ -101,10 +103,11 @@ export class CallInterfaceViolationError extends Error { } } -export class NotPublishableError extends Error { +/** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */ +export class NotClosedError extends Error { constructor(readonly report: ClosureReport) { - super(`流程包不闭合,不得发布:${report.violations.map((v) => v.message).join('; ')}`); - this.name = 'NotPublishableError'; + super(`流程包不闭合,不得保存:${report.violations.map((v) => v.message).join('; ')}`); + this.name = 'NotClosedError'; } } diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index b3161e3..e68f9ad 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -18,7 +18,7 @@ export type { WorkflowRecord, WorkflowState, } from './contract.ts'; -export { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from './contract.ts'; +export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts'; export type { ActivityDirectory, @@ -46,7 +46,7 @@ export { startNodeOf, } from './internal/domain/routing.ts'; -export { checkClosure, publishable } from './internal/domain/closure.ts'; +export { checkClosure, closed } from './internal/domain/closure.ts'; export { aggregateWorkflowState, FAILURE_DIGESTIONS, diff --git a/apps/api/src/modules/process/internal/adapter/process-store.ts b/apps/api/src/modules/process/internal/adapter/process-store.ts index 342e51f..778f0ed 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -30,14 +30,15 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord { export function createProcessStore(db: Db): ProcessStore { return { + /* + * **只插不改**:版本一旦落库就是不可变的历史。 + * 版本号由用例层算(恒新增),所以这里不该撞键;真撞了就让唯一约束报出来 —— + * 静默覆盖会改掉在途任务正在读的定义,那是最不该无声发生的事。 + */ async savePackage(pkg: ProcessPackage) { await db .insert(processPackageTable) - .values({ id: pkg.id, version: pkg.version, name: pkg.name, state: pkg.state, definition: pkg }) - .onConflictDoUpdate({ - target: [processPackageTable.id, processPackageTable.version], - set: { name: pkg.name, state: pkg.state, definition: pkg }, - }); + .values({ id: pkg.id, version: pkg.version, name: pkg.name, state: pkg.state, definition: pkg }); }, async getPackage(id, version) { @@ -57,21 +58,6 @@ export function createProcessStore(db: Db): ProcessStore { return rows.map((r) => processPackageSchema.parse(r.definition)); }, - async publishPackage(id, version) { - const rows = await db - .select() - .from(processPackageTable) - .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))) - .limit(1); - const row = rows[0]; - if (row === undefined) return; - const definition = { ...(row.definition as Record), state: 'published' }; - await db - .update(processPackageTable) - .set({ state: 'published', publishedAt: new Date(), definition }) - .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))); - }, - async insertWorkflow(record) { await db.insert(workflowTable).values({ id: record.id, diff --git a/apps/api/src/modules/process/internal/domain/closure.ts b/apps/api/src/modules/process/internal/domain/closure.ts index 8b4aacd..fc2b032 100644 --- a/apps/api/src/modules/process/internal/domain/closure.ts +++ b/apps/api/src/modules/process/internal/domain/closure.ts @@ -1,11 +1,14 @@ /** * 边映射闭合校验 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03)。 * - * 「**连线即映射,画图即校验,不闭合不得发布。** + * 「**连线即映射,画图即校验,不闭合不得保存。** * 连一条边即须完成该边映射(必填未映射 = 边未完成),类型兼容依两端契约 schema 当场校验; - * 存在未完成边、未满足必填或类型冲突的流程包**可存草稿、不得发布**。 + * 存在未完成边、未满足必填或类型冲突的流程包**存不进来**。 * AND 汇合后的同名字段必须**显式指定来源分支**,不做隐式合并。」 * + * 原文是「可存草稿、不得发布」——**已不成立**:没有草稿态也没有发布门, + * 闸落在保存上(严格检查 + 保存即生效)。画布上的半成品留在浏览器里,存不进库。 + * * 前端画布的即时校验与后端裁决**同源** —— 这一份实现两边共用。 */ import type { ClosureReport, ClosureViolation, Edge, ProcessNode, ProcessPackage } from '@contracts'; @@ -70,7 +73,7 @@ export function checkClosure(input: ClosureInput): ClosureReport { violations.push(...checkTolerateDependencies(pkg, contracts, byId)); - return { publishable: violations.length === 0, violations }; + return { closed: violations.length === 0, violations }; } function checkActivityNode( @@ -214,7 +217,7 @@ function checkTolerateDependencies( return out; } -/** 不闭合不得发布 —— 发布门的唯一判据 */ -export function publishable(report: ClosureReport): boolean { - return report.publishable; +/** 不闭合不得**保存** —— 没有发布这道门,闸落在保存上,这是它的唯一判据 */ +export function closed(report: ClosureReport): boolean { + return report.closed; } diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index f918d10..eef5c0b 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -5,7 +5,7 @@ */ import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts'; import type { ProcessService, StartWorkflowInput } from '../../contract.ts'; -import { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from '../../contract.ts'; +import { CallInterfaceViolationError, NotClosedError, PositionVacantError } from '../../contract.ts'; import type { ActivityDirectory, ApprovalGateway, @@ -47,19 +47,29 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { } /** - * 起单取版:未指定版本时取**最新已发布** —— 草稿不入选。 - * 若按「最新版本」取,治理台一存草稿,起单立刻被没发布的定义顶住; - * 草稿是编排中的半成品,运行时永远不该看见它。 + * 起单取版:未指定版本时取**最新的一版**。 + * + * 从前这里取「最新已发布」,因为草稿是编排中的半成品、运行时不该看见它。 + * 现在没有草稿了 —— 「严格检查 + 保存生效」,不闭合根本存不进来, + * 所以库里的每一版都是能起单的。生效就是这个意思:**新任务起单时取到最新版**, + * 在途的按启动时快照的版本号读定义、跑完旧编排。 + * `retired` 的版本拒起单(留给「这一版不再用了」)。 */ - async function latestPublished(id: string): Promise { + async function latest(id: string): Promise { const all = await deps.store.listPackages(); const hit = all - .filter((pkg) => pkg.id === id && pkg.state === 'published') + .filter((pkg) => pkg.id === id && pkg.state !== 'retired') .sort((a, b) => b.version - a.version)[0]; - if (hit === undefined) throw new Error(`流程包无已发布版本,不得起单:${id}`); + if (hit === undefined) throw new Error(`流程包无可用版本,不得起单:${id}`); return hit; } + /** 下一个版本号 —— 保存恒新增,不覆盖任何在途任务正在读的定义 */ + async function nextVersion(id: string): Promise { + const all = await deps.store.listPackages(); + return all.filter((pkg) => pkg.id === id).reduce((max, pkg) => Math.max(max, pkg.version), 0) + 1; + } + async function requireWorkflow(id: string): Promise { const record = await deps.store.getWorkflow(id); if (record === undefined) throw new Error(`工作流不存在:${id}`); @@ -268,9 +278,24 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { } return { + /** + * 保存 —— **严格检查 + 即刻生效**。没有发布这道门,所以闸落在这里。 + * + * 两件事一起做,顺序不能换: + * ① 先校验:不闭合直接抛,**一个字节都不落库**。存进来的就是能起单的。 + * ② 再定版:恒取「当前最大版本 + 1」,不采信入参的 version。 + * + * 为什么不能就地覆盖:工作流按 (包id, 版本) 去读定义。覆盖 v1 就等于改掉 + * 所有在途 v1 任务正在读的编排 —— 它们会中途换了规则却毫不知情, + * 而「旧任务走完旧版本」正是要防这个。恒新增之后,旧版本是不可变的历史, + * 在途任务读得到的永远是它启动那天的那一份。 + */ async savePackage(pkg) { - await deps.store.savePackage(pkg); - return pkg; + const report = await this.validate(pkg); + if (!report.closed) throw new NotClosedError(report); + const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'active' }; + await deps.store.savePackage(saved); + return saved; }, async listPackages() { @@ -291,21 +316,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { }, /** 不闭合不得发布 */ - async publish(id, version) { - const pkg = await requirePackage(id, version); - const report = await this.validate(pkg); - if (!report.publishable) throw new NotPublishableError(report); - await deps.store.publishPackage(id, version); - return report; - }, - /** 起单:经调用接口调起流程包,生成一条工作流 */ async start(input: StartWorkflowInput) { const pkg = input.packageVersion === undefined - ? await latestPublished(input.packageId) + ? await latest(input.packageId) : await requirePackage(input.packageId, input.packageVersion); - if (pkg.state !== 'published') throw new Error(`流程包未发布,不得起单:${pkg.id}@${pkg.version}`); + if (pkg.state === 'retired') throw new Error(`该版本已停用,不得起单:${pkg.id}@${pkg.version}`); // 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。 // (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。) diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index acde501..3a622a6 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -34,7 +34,6 @@ export type ProcessStore = { savePackage(pkg: ProcessPackage): Promise; getPackage(id: string, version?: number): Promise; listPackages(): Promise; - publishPackage(id: string, version: number): Promise; insertWorkflow(record: WorkflowRecord): Promise; getWorkflow(id: string): Promise; diff --git a/apps/web/src/features/gov-blueprint/internal/panel.tsx b/apps/web/src/features/gov-blueprint/internal/panel.tsx index 8725660..ac50ac1 100644 --- a/apps/web/src/features/gov-blueprint/internal/panel.tsx +++ b/apps/web/src/features/gov-blueprint/internal/panel.tsx @@ -16,7 +16,6 @@ import { useGovActivities, useGovObjects, useGovPackages, - useGovPkgReports, useGovTools, useGovTriggers, } from '@web-shared/gov/gov-api.ts'; @@ -31,11 +30,12 @@ export function BlueprintHome({ nav }: HomeProps) { const trgs = useGovTriggers(); const packages = pkgs.data?.packages ?? []; - const reports = useGovPkgReports(packages); - const reportOf = (id: string, version: number) => reports.data?.get(`${id}@${version}`); - const drafts = packages.filter((p) => p.state !== 'published'); - const draftBad = drafts.filter((p) => reportOf(p.id, p.version)?.publishable === false); + /* + * 没有「草稿」也没有「未闭合」可数了 —— 严格检查 + 保存即生效:不闭合的包**存不进来**。 + * 硬摆这两个计数就是摆两个恒为 0 的假指标。改数真能说话的:每条流有几版、最新是第几版。 + */ + const flowCount = new Set(packages.map((p) => p.id)).size; const activities = acts.data?.activities ?? []; const toolRows = tools.data?.tools ?? []; const objectRows = objs.data?.objects ?? []; @@ -57,7 +57,7 @@ export function BlueprintHome({ nav }: HomeProps) { (id) => !triggers.some((t) => t.processPackageId === id), ); - const gaps = draftBad.length + missingActivities.length + uncalled.length; + const gaps = missingActivities.length + uncalled.length; // 因果链是这一屏的主角:四库以真箭头相连 —— 箭头编码的是真实依赖 // (流程包引用活动、活动调工具、活动物化数据),节点卡即入口,点卡即入库。 @@ -71,9 +71,9 @@ export function BlueprintHome({ nav }: HomeProps) { 战略 · 流程包 - {packages.length} + {flowCount} - 企业以哪些流转运转 —— 草稿 {drafts.length} · 未闭合 {draftBad.length} + 企业以哪些流转运转 —— 共 {packages.length} 个版本,保存即生效 @@ -124,32 +124,9 @@ export function BlueprintHome({ nav }: HomeProps) { {gaps === 0 ? ( - 没有缺口 —— 流程包全部闭合,引用的活动都装好了,每条流也都有触发器调得起来。 + 没有缺口 —— 引用的活动都装好了,每条流也都有触发器调得起来。 ) : (
- {draftBad.map((p) => ( - v.message).join(';') || - '存在未通过闭合校验的声明' - } - acts={[ - { label: '去编排', ico: 'route', onClick: () => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` }) }, - { - label: '看边映射', - ico: 'git-merge', - onClick: () => { - nav.push({ k: 'pkg', id: `${p.id}@${p.version}` }); - nav.push({ k: 'edges', id: `${p.id}@${p.version}` }); - }, - }, - ]} - /> - ))} {missingActivities.map((id) => ( > = { export function CockpitHome({ nav }: HomeProps) { const metrics = useGovMetrics(); const tasks = useGovTasks(); - const packages = useGovPackages(); - const reports = useGovPkgReports(packages.data?.packages ?? []); const m = metrics.data; const workflows = tasks.data?.workflows ?? []; const taskRows = tasks.data?.tasks ?? []; const abnormal = workflows.filter((w) => w.state === 'abnormal').length; const running = taskRows.filter((t) => t.terminal === undefined).length; - const draftBad = (packages.data?.packages ?? []).filter( - (p) => p.state !== 'published' && reports.data?.get(`${p.id}@${p.version}`)?.publishable === false, - ).length; const mix = m?.terminalMix ?? {}; const mixTop = Object.entries(mix) @@ -72,7 +65,12 @@ export function CockpitHome({ nav }: HomeProps) { value={abnormal} sub="失败走向未消化,待人工处置" /> - + {/* + * 「未闭合草稿」这张卡撤了 —— 严格检查 + 保存即生效之后,不闭合的包**存不进来**, + * 这个数结构上恒为 0。摆一个永远是 0 的卡位就是在占地方装样子。 + * 天然的第三张是「死路」(网关一条边都判不中而卡住的流,已有 route-dead-end 事件), + * 但那需要一条按事件类型聚合的查询路由 —— 没有之前不摆(同死信卡的规矩)。 + */}
{abnormal > 0 ? ( diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index 2c3e729..b31cb13 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -5,13 +5,13 @@ * `WorkflowsHome` / `PkgPage` / `EdgesPage` 平移:DOM 结构、类名(`g-*`)与文案原样保留, * mock 常量换成服务端投影(README 平移规则 1/2/4)。 * - * 编排即定契约:连线即映射、画图即校验、**边不闭合不得发布**; + * 编排即定契约:连线即映射、画图即校验、**边不闭合不得保存**; * 图画完可导出活动开发的依赖文件 —— 开发者对着声明在系统外开发。 */ import { useEffect, useMemo, useRef, useState } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts'; -import { publishProcessRoute, saveProcessRoute, validateProcessRoute, TRIGGER_KIND_LABEL } from '@contracts'; +import { saveProcessRoute, validateProcessRoute, TRIGGER_KIND_LABEL } from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; import { Ico } from '@web-shared/Ico.tsx'; import { @@ -79,15 +79,21 @@ function PackagesHome({ tab, nav }: HomeProps) { return (
+ {/* + * 没有「已发布 / 草稿」了 —— 严格检查 + 保存即生效。 + * 真正的区别是「哪一版会被新起单取到」:同一条流里**版本号最大的那一版生效中**, + * 更早的版本是历史(在途任务还照它跑完)。 + */} {packages.map((p) => { - const published = p.state === 'published'; + const latestOfFlow = Math.max(...packages.filter((x) => x.id === p.id).map((x) => x.version)); + const live = p.version === latestOfFlow && p.state !== 'retired'; const ec = edgeCheckOf(p, reportOf(p)); const okEdge = ec.closed === ec.total; const c = counts(p); return (