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 46ec3b4..3d15ece 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -54,6 +54,10 @@ export type ProcessService = { getWorkflow(id: string): Promise; listWorkflows(limit?: number): Promise; instancesOf(workflowId: string): Promise; + /** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */ + deleteVersion(id: string, version: number): Promise; + /** 改名:名字是这条流的身份,一次改及于所有版本 */ + rename(id: string, name: string): Promise; nodeOwners(packageId: string): Promise>; setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise; /** 任务终态到达:完成即继续流转;非完成即走声明的失败走向 */ @@ -68,6 +72,12 @@ export type ProcessService = { terminate(workflowId: string, reason: string): Promise; }; /** 起单入参不合调用接口契约 —— 在建工作流之前判掉,不留半条在途单 */ +/** 生效版不可删 —— 先停用:这道闸挡的是「手滑把正在起单的定义删了」 */ +export declare class ActiveVersionError extends Error { + readonly id: string; + readonly version: number; + constructor(id: string, version: number); +} export declare class CallInterfaceViolationError extends Error { readonly packageId: string; readonly errors: readonly string[]; 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 6098dda..cf270de 100644 --- a/api-report/apps/api/src/modules/process/index.d.ts +++ b/api-report/apps/api/src/modules/process/index.d.ts @@ -2,7 +2,7 @@ * 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, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts'; +export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts'; 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'; 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 ec9d429..2a90c42 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -67,6 +67,9 @@ export type ProcessStore = { countPinnedInFlight(id: string, version: number): Promise; /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; + deletePackage(id: string, version: number): Promise; + /** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */ + renameFlow(id: string, name: string): Promise; getPackage(id: string, version?: number): Promise; listPackages(): Promise; insertWorkflow(record: WorkflowRecord): Promise; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 909bbdc..c543df0 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -813,10 +813,8 @@ export declare const saveProcessRoute: { closed: z.ZodBoolean; violations: z.ZodArray; nodeId: z.ZodOptional>; @@ -959,6 +957,257 @@ export declare const setProcessStateRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** + * 删一版 —— 两道闸(2026-07-30): + * · **生效版不可删**:先停用,免得手滑把正在起单的定义删了; + * · **在途钉版不可删**:那些单要按它跑完,定义没了就再也推不动。 + */ +export declare const deleteProcessVersionRoute: { + readonly method: "DELETE"; + readonly path: "/api/gov/process/:id/:version"; + readonly params: z.ZodObject<{ + id: z.ZodString; + version: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + packages: z.ZodArray; + name: z.ZodString; + version: z.ZodNumber; + state: z.ZodEnum<{ + active: "active"; + retired: "retired"; + }>; + callInterface: z.ZodObject<{ + input: z.ZodRecord; + output: z.ZodRecord; + }, z.core.$strip>; + nodes: z.ZodArray; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; + activityVersion: z.ZodOptional; + failure: z.ZodDefault; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"reroute">; + edgeId: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"escalate">; + approvalNodeId: z.core.$ZodBranded; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"tolerate">; + }, z.core.$strip>], "kind">>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"approval">; + reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"reopen">; + nodeId: z.core.$ZodBranded; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"terminate">; + }, z.core.$strip>], "kind">; + nudge: z.ZodOptional; + }, z.core.$strip>>>; + }, z.core.$strip>>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>], "kind">>; + edges: z.ZodArray; + to: z.core.$ZodBranded; + condition: z.ZodOptional; + label: z.ZodOptional; + tone: z.ZodOptional>; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + diagram: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** + * 改名 —— 名字是**这条流的身份**,不是某一版的属性:一次改及于它的所有版本。 + * (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。) + */ +export declare const renameProcessRoute: { + readonly method: "POST"; + readonly path: "/api/gov/process/:id/rename"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + name: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + packages: z.ZodArray; + name: z.ZodString; + version: z.ZodNumber; + state: z.ZodEnum<{ + active: "active"; + retired: "retired"; + }>; + callInterface: z.ZodObject<{ + input: z.ZodRecord; + output: z.ZodRecord; + }, z.core.$strip>; + nodes: z.ZodArray; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"end">; + output: z.ZodOptional>; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"activity">; + activityId: z.core.$ZodBranded; + in: z.ZodOptional>; + out: z.ZodOptional>; + activityVersion: z.ZodOptional; + failure: z.ZodDefault; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"reroute">; + edgeId: z.ZodString; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"escalate">; + approvalNodeId: z.core.$ZodBranded; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"tolerate">; + }, z.core.$strip>], "kind">>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"approval">; + reject: z.ZodDiscriminatedUnion<[z.ZodObject<{ + kind: z.ZodLiteral<"reopen">; + nodeId: z.core.$ZodBranded; + }, z.core.$strip>, z.ZodObject<{ + kind: z.ZodLiteral<"terminate">; + }, z.core.$strip>], "kind">; + nudge: z.ZodOptional; + }, z.core.$strip>>>; + }, z.core.$strip>>; + message: z.ZodOptional; + id: z.core.$ZodBranded; + label: z.ZodString; + sub: z.ZodOptional; + layout: z.ZodOptional; + y: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>], "kind">>; + edges: z.ZodArray; + to: z.core.$ZodBranded; + condition: z.ZodOptional; + label: z.ZodOptional; + tone: z.ZodOptional>; + reopen: z.ZodDefault; + }, z.core.$strip>>; + variables: z.ZodDefault; + }, z.core.$strip>>>; + description: z.ZodOptional; + diagram: z.ZodOptional>; + }, z.core.$strip>>; + }, z.core.$strip>; +}; /** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */ export declare const validateProcessRoute: { readonly method: "POST"; @@ -1079,10 +1328,8 @@ export declare const validateProcessRoute: { closed: z.ZodBoolean; violations: z.ZodArray; nodeId: z.ZodOptional>; @@ -1798,7 +2045,10 @@ export declare const listActivitiesRoute: { outputPorts: z.ZodArray; - fields: z.ZodArray; + fields: z.ZodArray>; }, z.core.$strip>>; tools: z.ZodArray; models: z.ZodArray; diff --git a/api-report/packages/contracts/src/process-package.d.ts b/api-report/packages/contracts/src/process-package.d.ts index ebc2216..e6e6682 100644 --- a/api-report/packages/contracts/src/process-package.d.ts +++ b/api-report/packages/contracts/src/process-package.d.ts @@ -322,21 +322,22 @@ export declare const processPackageSchema: z.ZodObject<{ diagram: z.ZodOptional>; }, z.core.$strip>; export type ProcessPackage = z.infer; -export declare const CLOSURE_VIOLATION_KINDS: readonly ["type-conflict", "dangling-node", "multi-default-edge", "tolerate-required-dependency", "unknown-activity"]; +/** + * 激活闸只有两面(2026-07-30 用户定「字段只声明,不做任何要求」): + * **结构闭合**(dangling-node / multi-default-edge)与**活动装载**(unknown-activity)。 + * 字段类的检查全部退役 —— 要什么、够不够、类型对不对归活动自己,不在编排层现形。 + */ +export declare const CLOSURE_VIOLATION_KINDS: readonly ["dangling-node", "multi-default-edge", "unknown-activity"]; export declare const closureViolationKindSchema: z.ZodEnum<{ - "type-conflict": "type-conflict"; "dangling-node": "dangling-node"; "multi-default-edge": "multi-default-edge"; - "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; }>; export type ClosureViolationKind = z.infer; export declare const closureViolationSchema: z.ZodObject<{ kind: z.ZodEnum<{ - "type-conflict": "type-conflict"; "dangling-node": "dangling-node"; "multi-default-edge": "multi-default-edge"; - "tolerate-required-dependency": "tolerate-required-dependency"; "unknown-activity": "unknown-activity"; }>; nodeId: z.ZodOptional>; @@ -349,10 +350,8 @@ export declare const closureReportSchema: z.ZodObject<{ closed: z.ZodBoolean; violations: z.ZodArray; nodeId: z.ZodOptional>; diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 0f9b631..41243e0 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -35,6 +35,8 @@ import { resumeTaskRoute, staffIdSchema, taskIdSchema, + deleteProcessVersionRoute, + renameProcessRoute, setNodeOwnerRoute, setTriggerConfirmRoute, listNodeOwnersRoute, @@ -47,7 +49,7 @@ import { extractFields } from '@modules/channel'; import { assembleInput, scopeOf } from '@modules/trigger'; import { InstallRejectedError } from '@modules/activity-registry'; import type { InstallVerdict } from '@modules/activity-registry'; -import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process'; +import { ActiveVersionError, NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; @@ -486,6 +488,40 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai }); /** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */ + /** 删一版:生效版先停用(409),在途钉版不许删(409)—— 那些单要按它跑完 */ + app.delete(deleteProcessVersionRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + try { + const packages = await container.process.deleteVersion(c.req.param('id'), Number(c.req.param('version'))); + return c.json({ packages }); + } catch (error) { + if (error instanceof ActiveVersionError || error instanceof PinnedVersionError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) }; + return c.json(body, 404); + } + }); + + /** 改名:名字是这条流的身份 —— 一次改及于所有版本;在途单不受影响(名字不参与流转) */ + app.post(renameProcessRoute.path, async (c) => { + const denied = await governProcess(c.get('account')); + if (denied) return c.json(denied, denyStatus(denied)); + const parsed = renameProcessRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '流程名不合协议(1–40 字)' }; + return c.json(body, 400); + } + try { + return c.json({ packages: await container.process.rename(c.req.param('id'), parsed.data.name.trim()) }); + } catch (error) { + const body: ApiError = { code: 'not-found', message: error instanceof Error ? error.message : String(error) }; + return c.json(body, 404); + } + }); + app.post(setProcessStateRoute.path, async (c) => { const denied = await governProcess(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); @@ -661,21 +697,25 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai const faces = await Promise.all( cards.map(async (card) => { const manifest = await container.registry.manifestOf(card.activityId); + type FieldSpec = { type?: string; items?: { type?: string } }; const data = manifest.contract.input.data as { required?: string[]; - properties?: Record; + properties?: Record; }; const required = data.required ?? []; const props = data.properties ?? {}; + /** 类型的人读写法 —— **数组写成 `T[]`**:声明得看得出是一个还是一串 */ + const typeOf = (spec: FieldSpec | undefined): string => + spec?.type === 'array' ? `${spec.items?.type ?? 'any'}[]` : (spec?.type ?? 'unknown'); const portFields = (payload: unknown) => { - const p = payload as { properties?: Record } | undefined; - return Object.keys(p?.properties ?? {}); + const p = payload as { properties?: Record } | undefined; + return Object.entries(p?.properties ?? {}).map(([name, spec]) => ({ name, type: typeOf(spec) })); }; return { requiredInputs: required, inputFields: Object.entries(props).map(([name, spec]) => ({ name, - type: spec.type ?? 'unknown', + type: typeOf(spec), required: required.includes(name), })), // 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游 diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 05bff74..c975dd2 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 用户定):编排层不校验字段 —— 要什么、够不够、 + 类型对不对全归活动自己。激活闸只剩两面:**结构闭合**(dangling-node / multi-default-edge) + 与**活动装载**(unknown-activity);字段类的三条检查(边未映射 / 必填未映射 / 类型相抵)全退役。 + - **版本可删,两道闸**:生效版不可删(先停用,`ActiveVersionError`)、在途钉版不可删 + (那些单要按它跑完,`PinnedVersionError`)。**改名及于整条流的所有版本** —— + 名字是流的身份,不是某一版的属性。 - **数据按字段名流动**(2026-07-30 对齐 Mastra,替代「连线即映射」):边不带映射, 只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域 (起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取; diff --git a/apps/api/src/modules/process/__tests__/closure.test.ts b/apps/api/src/modules/process/__tests__/closure.test.ts index d4dabbd..57d7d90 100644 --- a/apps/api/src/modules/process/__tests__/closure.test.ts +++ b/apps/api/src/modules/process/__tests__/closure.test.ts @@ -56,8 +56,8 @@ describe('checkClosure —— 不闭合不得激活', () => { expect(report.closed).toBe(true); }); - it('同名字段类型相抵即拦 —— 名字即管道,名同型异必坏', () => { - // 上游 done 口发 quote: number …下游若声明 quote: string 就是硬伤 + it('字段一概不查(2026-07-30「只声明,不做任何要求」)—— 名同型异也不拦激活', () => { + // 上游 done 口发 quote: number,下游声明 quote: string:从前是 type-conflict,现在归活动自己管 const clash = new Map([ ['a4', face({ inputTypes: { pn: 'string', quote: 'string' }, requiredInputs: ['pn'] })], ]); @@ -74,8 +74,7 @@ describe('checkClosure —— 不闭合不得激活', () => { { id: 'e2', from: 'A4' as never, to: 'end' as never, reopen: false }, ], }); - const violations = checkClosure({ pkg: chain, contracts: clash }).violations; - expect(violations.filter((v) => v.kind === 'type-conflict').map((v) => v.field)).toEqual(['quote']); + expect(checkClosure({ pkg: chain, contracts: clash }).closed, '结构闭合、活动在库 —— 就该激活得了').toBe(true); }); it('引用未安装的活动即拦', () => { @@ -124,40 +123,6 @@ describe('checkClosure —— 不闭合不得激活', () => { ).toEqual([]); }); - it('容忍分支的输出不得被下游必填依赖', () => { - const tolerant = pkg({ - nodes: [ - { id: 'start' as never, kind: 'start', label: '起点' }, - { - id: 'A3' as never, - kind: 'activity', - label: 'A3 渠道寻源', - activityId: 'a4' as never, - failure: { kind: 'tolerate' }, - }, - { - id: 'A4' as never, - kind: 'activity', - label: 'A4 成本定价', - activityId: 'a4' as never, - failure: { kind: 'terminate' }, - }, - { id: 'end' as never, kind: 'end', label: '终点' }, - ], - edges: [ - { 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 violations = checkClosure({ pkg: tolerant, contracts }).violations; - expect(violations.map((v) => v.kind)).toContain('tolerate-required-dependency'); - }); }); describe('resolveOwner —— 四策略确定性解析', () => { diff --git a/apps/api/src/modules/process/__tests__/save-package.test.ts b/apps/api/src/modules/process/__tests__/save-package.test.ts index 86d1dd8..95f57a9 100644 --- a/apps/api/src/modules/process/__tests__/save-package.test.ts +++ b/apps/api/src/modules/process/__tests__/save-package.test.ts @@ -58,6 +58,8 @@ function harness(seed: ProcessPackage[], pinned = 0) { insertInstance: async () => undefined, updateInstance: async () => undefined, findInstanceByTask: async () => undefined, + deletePackage: async () => undefined, + renameFlow: async () => undefined, nodeOwners: async () => ({}), setNodeOwner: async () => undefined, }, 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 1fa6b07..d5088e5 100644 --- a/apps/api/src/modules/process/__tests__/start-guard.test.ts +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -68,6 +68,8 @@ function harness(validationErrors: readonly string[]): Harness { }, updateInstance: async () => undefined, findInstanceByTask: async () => undefined, + deletePackage: async () => undefined, + renameFlow: async () => undefined, nodeOwners: async () => ({}), setNodeOwner: async () => undefined, }, diff --git a/apps/api/src/modules/process/__tests__/version-lifecycle.test.ts b/apps/api/src/modules/process/__tests__/version-lifecycle.test.ts new file mode 100644 index 0000000..44ea43c --- /dev/null +++ b/apps/api/src/modules/process/__tests__/version-lifecycle.test.ts @@ -0,0 +1,117 @@ +/** + * 版本的删与改名(2026-07-30 用户要的两件)—— 守两道闸、一条口径。 + * + * · **生效版不可删**:先停用。挡的是「手滑把正在起单的定义删了」; + * · **在途钉版不可删**:那些工作流按启动时钉住的这一版跑完,定义没了就再也推不动 + * —— 与「在途钉版不可改」同一条理由、同一个错(PinnedVersionError); + * · **改名及于整条流的所有版本**:名字是流的身份,不是某一版的属性。 + * (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。) + */ +import { describe, expect, it } from 'vitest'; +import type { ProcessPackage } from '@contracts'; +import { processPackageSchema } from '@contracts'; +import { ActiveVersionError, PinnedVersionError } from '../contract.ts'; +import { createProcessService } from '../internal/usecase/process-service.ts'; +import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts'; + +const pkg = (version: number, state: 'active' | 'retired'): ProcessPackage => + processPackageSchema.parse({ + id: 'quote', + name: '统一报价', + version, + state, + callInterface: { input: { type: 'object' }, output: { type: 'object' } }, + nodes: [ + { kind: 'start', id: 'S', label: '开始' }, + { kind: 'end', id: 'E', label: '结束' }, + ], + edges: [{ id: 'e1', from: 'S', to: 'E', reopen: false }], + variables: [], + }); + +type Harness = { + service: ReturnType; + rows: ProcessPackage[]; +}; + +function harness(rows: ProcessPackage[], pinned = 0): Harness { + const live = [...rows]; + const deps: ProcessServiceDeps = { + store: { + savePackage: async () => undefined, + updatePackageDefinition: async () => undefined, + countPinnedInFlight: async () => pinned, + setPackageState: async () => undefined, + deletePackage: async (id: string, version: number) => { + const i = live.findIndex((p) => p.id === id && p.version === version); + if (i >= 0) live.splice(i, 1); + }, + renameFlow: async (id: string, name: string) => { + for (let i = 0; i < live.length; i += 1) { + if (live[i]!.id === id) live[i] = { ...live[i]!, name }; + } + }, + getPackage: async (id: string, version?: number) => + live.find((p) => p.id === id && (version === undefined || p.version === version)), + listPackages: async () => live, + insertWorkflow: async () => undefined, + getWorkflow: async () => undefined, + updateWorkflow: async () => undefined as never, + listWorkflows: async () => [], + insertInstance: async () => undefined, + updateInstance: async () => undefined, + listInstances: async () => [], + findInstanceByTask: async () => undefined, + nodeOwners: async () => ({}), + setNodeOwner: async () => undefined, + }, + activities: { contractFaceOf: async () => undefined }, + tasks: { start: async () => 't-1' as never, terminate: async () => undefined }, + approvals: { raise: async () => undefined }, + org: { resolveOwner: async () => 's-1' as never, workGroupBinding: async () => undefined }, + events: { publish: async () => undefined }, + receipts: { notify: async () => undefined }, + schemas: { validate: () => ({ ok: true, errors: [] }) }, + sfeel: { condition: () => true, expression: () => undefined }, + trace: { + beginWorkflow: async () => undefined, + beginNode: async () => undefined, + endNode: async () => undefined, + endWorkflow: async () => undefined, + } as never, + clock: { now: () => new Date('2026-07-30T00:00:00.000Z') }, + ids: { next: () => 'x' }, + } as never; + return { service: createProcessService(deps), rows: live }; +} + +describe('版本的删与改名', () => { + it('停用版可删 —— 删完列表里就没有它了', async () => { + const h = harness([pkg(2, 'active'), pkg(1, 'retired')]); + const after = await h.service.deleteVersion('quote', 1); + expect(after.map((p) => p.version)).toEqual([2]); + }); + + it('生效版不可删 —— 先停用', async () => { + const h = harness([pkg(2, 'active'), pkg(1, 'retired')]); + await expect(h.service.deleteVersion('quote', 2)).rejects.toBeInstanceOf(ActiveVersionError); + expect(h.rows.map((p) => p.version), '一行都没动').toEqual([2, 1]); + }); + + it('在途钉版不可删 —— 那些单要按它跑完', async () => { + const h = harness([pkg(2, 'active'), pkg(1, 'retired')], 3); + await expect(h.service.deleteVersion('quote', 1)).rejects.toBeInstanceOf(PinnedVersionError); + expect(h.rows).toHaveLength(2); + }); + + it('改名及于所有版本 —— 名字是这条流的身份', async () => { + const h = harness([pkg(2, 'active'), pkg(1, 'retired')]); + const after = await h.service.rename('quote', '统一报价(新)'); + expect(after.map((p) => p.name)).toEqual(['统一报价(新)', '统一报价(新)']); + }); + + it('改不存在的流:如实报,不静默建一条', async () => { + const h = harness([pkg(1, 'retired')]); + await expect(h.service.rename('nope', 'X')).rejects.toThrow(/不存在/); + }); +}); diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index 8796acb..0151cd6 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -79,6 +79,11 @@ export type ProcessService = { listWorkflows(limit?: number): Promise; instancesOf(workflowId: string): Promise; + /** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */ + deleteVersion(id: string, version: number): Promise; + /** 改名:名字是这条流的身份,一次改及于所有版本 */ + rename(id: string, name: string): Promise; + /* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */ nodeOwners(packageId: string): Promise>; setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise; @@ -109,6 +114,14 @@ export type ProcessService = { }; /** 起单入参不合调用接口契约 —— 在建工作流之前判掉,不留半条在途单 */ +/** 生效版不可删 —— 先停用:这道闸挡的是「手滑把正在起单的定义删了」 */ +export class ActiveVersionError extends Error { + constructor(readonly id: string, readonly version: number) { + super(`v${version} 正在生效,不能删 —— 先停用它`); + this.name = 'ActiveVersionError'; + } +} + export class CallInterfaceViolationError extends Error { constructor( readonly packageId: string, diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index 1fef039..c829c79 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, NotClosedError, +export { ActiveVersionError, CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts'; export type { 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 363447a..ac921ac 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -58,6 +58,24 @@ export function createProcessStore(db: Db): ProcessStore { .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))); }, + async deletePackage(id, version) { + await db + .delete(processPackageTable) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))); + }, + + async renameFlow(id, name) { + // 名字在列上与 definition(jsonb)里各有一份 —— 与 setPackageState 同一口径,两处一起改 + const rows = await db.select().from(processPackageTable).where(eq(processPackageTable.id, id)); + for (const row of rows) { + const definition = { ...(row.definition as Record), name }; + await db + .update(processPackageTable) + .set({ name, definition }) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, row.version))); + } + }, + async getPackage(id, version) { const conditions = [eq(processPackageTable.id, id)]; if (version !== undefined) conditions.push(eq(processPackageTable.version, version)); diff --git a/apps/api/src/modules/process/internal/domain/closure.ts b/apps/api/src/modules/process/internal/domain/closure.ts index 8c44e33..3d3af0c 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, byId)); + violations.push(...checkActivityNode(node, contracts)); } // 岗位不入编排(活动点与审批点同一口径,2026-07-28 第五轮):没有岗位可核 /* @@ -77,16 +77,13 @@ export function checkClosure(input: ClosureInput): ClosureReport { } } - violations.push(...checkTolerateDependencies(pkg, contracts, byId)); - return { closed: violations.length === 0, violations }; } +/** 活动点只查一件事:**引用的活动装没装**(判据二);字段一概不查(只声明,不要求) */ function checkActivityNode( node: Extract, - inbound: readonly Edge[], contracts: ReadonlyMap, - byId: ReadonlyMap, ): ClosureViolation[] { const out: ClosureViolation[] = []; const face = contracts.get(node.activityId); @@ -101,30 +98,10 @@ function checkActivityNode( } /* - * 数据按字段名流动(2026-07-30 对齐 Mastra):边不带映射,「必填有没有着落」 - * 不再是激活阻断 —— 宽进纪律下,缺的字段运行时由受理补全单找人收齐。 - * 激活闸只留 schema 咬合这一条硬伤:**上游产出与下游输入同名却不同型**, - * 名字既是管道,名同型异一定坏 —— 这必须在设计期拦下。 + * **字段只声明,不做任何要求**(2026-07-30 用户定):编排层不校验字段 —— + * 要什么、够不够、类型对不对,全归活动自己;缺了由活动内的实现去补,不在这一层现形。 + * 于是激活闸只剩两面:**结构闭合**(下面几条)与**活动装载**(上面的 unknown-activity)。 */ - for (const edge of inbound) { - 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}(名字即管道,名同型异必坏)`, - }); - } - } - } - } /* * 多入边**不再判「隐式合并」**(产品决策 2026-07-28:网关去节点化)。 @@ -136,46 +113,6 @@ function checkActivityNode( 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; - 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: required, - message: `容忍分支 ${edge.from} 产出的 ${required} 被下游必填依赖 —— 分支可空,依赖它的必填就会空`, - }); - } - } - } - return out; -} /** 不闭合不得**保存** —— 没有发布这道门,闸落在保存上,这是它的唯一判据 */ export function closed(report: ClosureReport): boolean { 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 98a330d..914b7c2 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 { ProcessPackage } from '@contracts'; import type { ProcessService, StartWorkflowInput } from '../../contract.ts'; -import { NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts'; +import { ActiveVersionError, NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts'; import type { ActivityDirectory, ApprovalGateway, @@ -154,6 +154,32 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version); }, + async deleteVersion(id, version) { + const all = await deps.store.listPackages(); + const target = all.find((p) => p.id === id && p.version === version); + if (target === undefined) throw new Error(`流程包版本不存在:${id}@${version}`); + // 生效版不可删:先停用 —— 挡的是「手滑把正在起单的定义删了」 + if (target.state === 'active') throw new ActiveVersionError(id, version); + /* + * 在途钉版不可删:那些工作流按启动时钉住的这一版跑完,定义没了就再也推不动 —— + * 与「在途钉版不可改」同一条理由、同一个错。 + */ + const pinned = await deps.store.countPinnedInFlight(id, version); + if (pinned > 0) throw new PinnedVersionError(id, version, pinned); + await deps.store.deletePackage(id, version); + const after = await deps.store.listPackages(); + return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version); + }, + + async rename(id, name) { + const all = await deps.store.listPackages(); + if (!all.some((p) => p.id === id)) throw new Error(`流程包不存在:${id}`); + // 名字是这条流的身份 —— 一次改及于所有版本(在途单不受影响:名字不参与流转) + await deps.store.renameFlow(id, name); + const after = await deps.store.listPackages(); + return after.filter((p) => p.id === id).sort((a, b) => b.version - a.version); + }, + async listPackages() { return deps.store.listPackages(); }, diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index f8da518..13d9ce4 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -76,6 +76,9 @@ export type ProcessStore = { countPinnedInFlight(id: string, version: number): Promise; /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; + deletePackage(id: string, version: number): Promise; + /** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */ + renameFlow(id: string, name: string): Promise; getPackage(id: string, version?: number): Promise; listPackages(): Promise; diff --git a/apps/web/src/app/styles/console.css b/apps/web/src/app/styles/console.css index 4857112..a727844 100644 --- a/apps/web/src/app/styles/console.css +++ b/apps/web/src/app/styles/console.css @@ -341,10 +341,9 @@ /* 图自身按 aspect-ratio 缩放,整张一定看得全——纸面不再限高、不再内部滚动 */ .g-canvas .process-desk { flex: 1; min-height: 0; height: auto; max-height: none; overflow: visible; padding: 0; background: transparent; display: flex; flex-direction: column; } .g-canvas .process-doc { flex: 1; min-height: 0; box-sizing: border-box; max-width: none; padding: 14px 18px 18px; border-radius: 10px; box-shadow: var(--cshadow); display: flex; flex-direction: column; } -/* 图框吃满纸面,并在下方空出**恰好一条底栏的高度** —— 图框下沿与左列版本卡底栏 - (.g-vercard-f,净高 38)的上沿连成同一条横线。20 = 38(底栏净高) − 18(纸面下内距)。 - 纸面被栅格拉高时(版本多、左列更长)图框跟着长,才不会又浮回上面去。 */ -.g-canvas .process-doc > .hero { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 20px; } +/* 图框吃满纸面(纸被栅格拉高时跟着长,不会浮回上面);下沿留白与纸面左右内距同为 18, + 四边等距 —— 三栏改版后左列不再有底栏,原先那条「对齐底栏顶线」的 20px 就没有对齐对象了。 */ +.g-canvas .process-doc > .hero { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0; } .g-canvas .process-doc > .hero > .fig-body { flex: 1 1 auto; min-height: 0; } /* 画布上的选中态(外挂类,_flow 复刻件未改):命中层描边 = 选中框 */ .g-canvas .flow-node-sel .djs-hit { fill: rgba(51, 112, 255, .09) !important; stroke: #3370ff !important; stroke-width: 2.5px !important; stroke-opacity: 1 !important; rx: 7px; ry: 7px; } @@ -520,7 +519,11 @@ /* ── 流程详情:左版本列 + 右画布(补充)────────────────────────────── 版本收在流程内部:左列是这条流的全部版本(恰一个生效),点谁看谁的图; 属性区从右侧窄栏挪到图下整行(g-wide),声明式契约细节再往下。 */ -.g-flowlab { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(0, 2.2fr); gap: 12px; align-items: stretch; } +/* 三栏(2026-07-30):左**窄**版本条 · 中流程图 · 右节点属性。 + 右栏用包壳绝对定位 —— 行高由中栏的图定,属性长了在卡内滚,不把图拉长。 */ +.g-flowlab { display: grid; grid-template-columns: 132px minmax(0, 2.5fr) minmax(320px, 1.25fr); gap: 12px; align-items: stretch; } +.g-nodewrap { position: relative; min-height: 0; } +.g-nodewrap > .g-nodecard { position: absolute; inset: 0; height: auto; } /* 版本卡:与右侧的图**等高**(grid stretch),版本多了在卡内滚,不把页面撑长。 形制取**图纸风**,与右边的流程图同一套语言(flow-canvas.css 的 process-doc): 白纸同影同角、mono 小字距大写题头(VER — 前缀对 FIG — 前缀)、钢青/黄铜色系、 @@ -541,6 +544,27 @@ .g-ver-new:hover { border-color: #4e7daf; box-shadow: 0 0 0 2px rgba(78, 125, 175, .14); } .g-ver-new:disabled { opacity: .5; cursor: default; box-shadow: none; } .g-vercard-list { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; } + +/* ── 窄版本条:一版一行(v几 + 状态 + 计数),动作只在**看着的那一版**上展开 ── */ +.g-verslim .g-vercard-h { padding: 30px 10px 16px; gap: 4px; } +.g-verslim .g-vercard-list { padding: 10px 8px; gap: 6px; } +.g-verrow { display: flex; flex-direction: column; border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-steel); background: #fff; } +.g-verrow.on { border-color: #9fb0c0; background: #f2f5f8; } +.g-verrow.live { border-left-color: var(--sh-brass); background: var(--sh-brass-wash); } +.g-verrow.live.on { border-color: #cbb98d; } +.g-verrow-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 6px; padding: 7px 8px; border: 0; background: transparent; font-family: var(--sh-mono); font-size: 10px; color: #3a4452; text-align: left; cursor: pointer; } +.g-verrow-h > b { font-size: 12.5px; font-weight: 700; color: var(--sh-ink); } +.g-verrow-h .st { font-size: 9px; letter-spacing: .1em; color: var(--sh-muted); } +.g-verrow-h .st.live { color: var(--sh-brass); font-weight: 700; } +.g-verrow-h .cnt { flex-basis: 100%; font-size: 9px; color: var(--sh-muted); } +.g-verrow-a { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 8px 8px; } +.g-verrow-a .g-ver-act { flex: 1 1 auto; text-align: center; padding: 3px 6px; letter-spacing: .06em; } +.g-ver-act.t-bad { color: #b0413e; border-color: #e0bcba; } +.g-ver-act:disabled { opacity: .45; cursor: default; } +/* 改名条:名字改的是整条流,所以落在卡底、不挂在某一行上 */ +.g-verrename { flex: none; display: flex; flex-direction: column; gap: 5px; padding: 9px 10px; border-top: 1px solid var(--sh-line); background: #f9fafc; } +.g-verrename .g-input { height: 26px; font-size: 12px; } +.g-verrename .t { font-family: var(--sh-mono); font-size: 8.5px; line-height: 1.7; color: var(--sh-muted); } .g-ver { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: 1px solid var(--sh-line); border-left: 3px solid var(--sh-steel); background: #fff; cursor: pointer; font-family: var(--sh-mono); font-size: 11px; color: #3a4452; } .g-ver:hover { border-color: #aeb9c6; } .g-ver.on { background: #f2f5f8; border-color: #9fb0c0; } @@ -554,10 +578,6 @@ .g-ver-act { border: 1px solid var(--sh-line); background: #fff; padding: 2px 8px; font-family: inherit; font-size: 9.5px; letter-spacing: .1em; color: #3a4452; cursor: pointer; } .g-ver-act:hover { border-color: #aeb9c6; } .g-ver-act.t-go { color: var(--sh-brass); border-color: #d9c9a4; } -/* 卡底栏 —— 它的净高(1 顶线 + 10 + 17 行 + 10 = 38)是**两列共用的基线**: - 右侧画布的图框下沿要落在这条顶线上(见上面 .g-canvas .process-doc > .hero 的下外距)。 - 文案改长到换行、或改了内距,那边的 20px 要跟着算。 */ -.g-vercard-f { flex: none; min-height: 38px; box-sizing: border-box; padding: 10px 16px; border-top: 1px solid var(--sh-line); font-family: var(--sh-mono); font-size: 9.5px; line-height: 1.8; color: var(--sh-muted); } .g-ver { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--card); border: 1px solid var(--cline); border-radius: 11px; cursor: pointer; font: inherit; text-align: left; transition: border-color var(--t-fast), box-shadow var(--t-fast); } .g-ver:hover { border-color: #c9d6e6; } .g-ver.on { border-color: var(--brand); box-shadow: 0 0 0 2px var(--brand-wash); } @@ -572,35 +592,16 @@ 检验章(判据面全清的合格章)是唯一的戏眼,其余一律细线纪律。 */ /* ── 激活 · 双判据 → 检验单:上下两格判据面均分卡高,面头一行、面体列违规 ── */ -.g-exboard { min-height: 0; } -.g-exboard .g-vercard-h { height: auto; padding: 13px 18px 10px; } -.g-vercard-list.g-exsheet { padding: 0; gap: 0; overflow: hidden; } -.g-exface { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; } -.g-exface + .g-exface { border-top: 1px solid var(--sh-line); } -.g-exfacet { flex: none; display: flex; align-items: center; gap: 7px; padding: 10px 16px 9px; font: 600 9.5px/1 var(--sh-mono, var(--mono)); letter-spacing: 0.14em; text-transform: uppercase; color: var(--sh-steel, #46586a); border-bottom: 1px dashed #e4e9ef; } -.g-exfacet.ok { color: #2e7d4f; } -.g-exfacet .st { margin-left: auto; letter-spacing: .1em; text-align: right; } -.g-exfacet:not(.ok) .st { color: #b0413e; } -.g-exfacet:not(.ok) svg { color: #b0413e; } -.g-exface-b { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; padding: 10px 14px 12px; } /* 违规行:方角细线 + 红左线(压回全局 .g-ver 的圆角白卡版);点一条定位到节点属性 */ -.g-exface-b .g-ver { border-radius: 0; border: 1px solid var(--sh-line); border-left: 3px solid #b0413e; padding: 7px 10px; font-family: var(--sh-mono); box-shadow: none; } -.g-exface-b .g-ver:hover { border-color: #aeb9c6; border-left-color: #b0413e; } -.g-exface-b .g-ver:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } .g-ex { cursor: pointer; border-left-color: #b0413e; } .g-ex > b { font-size: 11px; } .g-ex .msg { flex: 1; min-width: 0; font-size: 10.5px; line-height: 1.55; letter-spacing: 0; text-transform: none; color: #3a4452; } /* 检验章:该面清零即盖 —— 双线方框微旋,检验单的通过凭证;半透边线是印泥感 */ -.g-exstamp { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 11px 20px 10px; border: 1.5px solid rgba(46, 125, 79, .55); outline: 1px solid rgba(46, 125, 79, .3); outline-offset: 3px; color: #2e7d4f; transform: rotate(-4deg); font: 700 12.5px/1.2 var(--sh-mono); letter-spacing: .34em; text-indent: .34em; } -.g-exstamp small { font-size: 7.5px; font-weight: 600; letter-spacing: .18em; text-indent: .18em; opacity: .7; } /* 第二面的章反向微旋 —— 两枚章不该是同一手势盖出来的 */ -.g-exface + .g-exface .g-exstamp { transform: rotate(2.5deg); } /* 裁定带:两面全清才出 —— 黄铜是「可生效」的颜色 */ -.g-exverdict { flex: none; padding: 9px 16px 9px 13px; border-top: 1px solid var(--sh-line); border-left: 3px solid var(--sh-brass); background: var(--sh-brass-wash); font-family: var(--sh-mono); font-size: 10px; line-height: 1.8; color: var(--sh-brass); } -.g-exboard-empty { padding: 16px 18px; font-family: var(--sh-mono); font-size: 10px; line-height: 1.9; color: var(--sh-muted); } /* ── 节点 · 属性 → 索引栏(固定)+ 明细区(滚动)────────────────────── */ -.g-nodecard { height: 560px; min-height: 0; } +.g-nodecard { min-height: 0; } .g-nodecard .g-vercard-h { height: auto; padding: 13px 18px 10px; } .g-nodecard .g-vercard-h .n { margin-left: auto; letter-spacing: .1em; } .g-nodecard .g-vercard-h .n.dirty { color: var(--sh-brass); } @@ -609,15 +610,6 @@ .g-nodecard-b > .g-note { flex: none; margin: 10px 14px 0; } /* 索引栏 = 图框分度带:浅底压一层,行首竖签(节点/边 + 计数),芯片缩成分度块 */ -.g-pickrail { flex: none; background: #f9fafc; border-bottom: 1px solid var(--sh-line); } -.g-pickrow { display: flex; align-items: flex-start; gap: 10px; padding: 8px 14px; } -.g-pickrow + .g-pickrow { border-top: 1px dashed #e4e9ef; } -.g-picklbl { flex: none; display: inline-flex; align-items: baseline; gap: 4px; min-width: 42px; padding-top: 6px; font: 600 9px/1 var(--sh-mono); letter-spacing: .14em; text-transform: uppercase; color: var(--sh-steel); } -.g-picklbl b { font-weight: 600; font-size: 9px; color: var(--sh-muted); } -.g-pickrow .g-picker { gap: 4px; } -.g-nodecard .g-pick { height: 22px; padding: 0 8px; gap: 5px; border-radius: 0; border-color: var(--sh-line); background: #fff; font-size: 11px; color: #3a4452; } -.g-nodecard .g-pick b { font-size: 9px; font-family: var(--sh-mono); } -.g-nodecard .g-pick:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; } /* 明细区:卡内唯一的滚动层 —— 索引在上钉住,拉表单不丢定位 */ .g-propsheet { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 14px; } @@ -628,9 +620,19 @@ .g-sheetempty b { font-size: 12px; font-weight: 700; color: var(--sh-steel); } .g-sheetempty p { margin: 0; font-size: 11px; line-height: 1.9; color: var(--sh-muted); } -/* 属性小卡:两列手排、按内容自高 —— 空白不再被 250 定高摊出来,长卡也不内滚; - 左列 = 身份与数据口,右列 = 类型与装载(JSX 里 .g-propcol 手排,语义成组、高度可控) */ -.g-nodecard .g-props { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 12px; } +/* 缺项清单:没选节点时占住空态 —— 原「激活判据」看板的活儿收进这里,点一条定位过去 */ +.g-lack { width: 100%; display: flex; flex-direction: column; gap: 6px; text-align: left; } +.g-lack-h { display: flex; align-items: center; gap: 7px; font: 600 9.5px/1 var(--sh-mono); letter-spacing: .14em; text-transform: uppercase; color: #b0413e; padding-bottom: 2px; } +.g-lack-r { display: flex; align-items: baseline; gap: 8px; width: 100%; padding: 7px 10px; border: 1px solid var(--sh-line); border-left: 3px solid #b0413e; background: #fff; font: inherit; font-size: 11px; line-height: 1.7; color: #3a4452; text-align: left; cursor: pointer; } +.g-lack-r:hover:not(:disabled) { border-color: #aeb9c6; border-left-color: #b0413e; } +.g-lack-r:disabled { cursor: default; opacity: .75; } +.g-lack-r > b { flex: none; font-family: var(--sh-mono); font-size: 10px; color: var(--sh-ink); } +/* 两面都齐:一枚小检验章,别再占一整张卡 */ +.g-okstamp { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; padding: 5px 11px; border: 1px solid rgba(46, 125, 79, .45); font: 600 10px/1 var(--sh-mono); letter-spacing: .08em; color: #2e7d4f; } + +/* 属性小卡:三栏改版后右栏是**窄轨**(≈335),两列摊不开 —— 改单列竖排, + 顺序即阅读次序:身份 → 数据接口 → 类型与责任 → 装载 → 出边。长了在明细区自己滚。 */ +.g-nodecard .g-props { display: flex; flex-direction: column; align-items: stretch; gap: 12px; } .g-propcol { min-width: 0; display: flex; flex-direction: column; gap: 12px; } .g-nodecard .g-props.g-eprops { display: flex; flex-direction: column; align-items: stretch; gap: 12px; } .g-nodecard .g-prop { border: 1px solid var(--sh-line); border-radius: 0; box-shadow: none; } diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index 8ef8954..1b1decc 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -2,8 +2,9 @@ * feature `gov-process` —— 流程包(设计态):**一条流一个包,版本收在包内部**。 * * 一区一内容:三枚计数摆在货架顶端,底下就是包货架(触发器已独立成区)。 - * 货架一流程一卡(不按版本平铺);进详情后左 1/3 是版本列表、右 2/3 是流程图, - * 图下是节点属性(身份 · 数据接口 · 装载 · 出边条件),再往下是调用接口与触发器。 + * 货架一流程一卡(不按版本平铺);进详情是**三栏**:左窄轨=版本(选中那版才出动作: + * 激活/停用 · 改名 · 删除)、中=流程图、右=节点属性(点图选节点,内容长了卡内滚,不拉长图)。 + * 「激活判据」不再单占一卡 —— 没选节点时,属性栏就地列出还差哪几条,点一条定位过去。 * * 版本是**运行时开关**,不是发布流程: * · 编辑即其身:保存**写回当前版本**(号不变);新版本只出自「新建」 @@ -14,7 +15,14 @@ import { useRef, useState, useEffect } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts'; -import { processPackageSchema, saveProcessRoute, setProcessStateRoute, validateProcessRoute } from '@contracts'; +import { + deleteProcessVersionRoute, + processPackageSchema, + renameProcessRoute, + saveProcessRoute, + setProcessStateRoute, + validateProcessRoute, +} from '@contracts'; import { call } from '@web-shared/typed-fetch.ts'; import { Ico } from '@web-shared/Ico.tsx'; import type { AskSpec } from '@web-shared/gov/primitives.tsx'; @@ -351,6 +359,8 @@ function FlowLab({ const [dirty, setDirty] = useState(false); const [report, setReport] = useState(undefined); const [ask, setAsk] = useState<{ spec: AskSpec; danger?: boolean } | undefined>(undefined); + /** 改名就地开一个输入框(名字改的是整条流,不是某一版) */ + const [renaming, setRenaming] = useState(false); const canvasRef = useRef(null); const nodes = graph?.nodes ?? []; @@ -365,49 +375,6 @@ 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], @@ -436,7 +403,6 @@ function FlowLab({ const kindLabel = (n: FlowNode) => n.kind === 'activity' ? (n.flavor === 'approval' ? '审批点' : '活动点') : n.kind === 'start' ? '调用接口' : '显式终点'; - const kindTone = (n: FlowNode) => (n.kind === 'activity' ? (n.flavor === 'approval' ? 'warn' : 'auto') : 'mute'); // 画布上的选中态:diagram-js 给每个形状挂了 data-element-id,外挂一个类,不改画布件 useEffect(() => { @@ -477,6 +443,34 @@ function FlowLab({ }, }); + /** 删一版:生效版与在途钉版都会被服务端挡回来(409),判词照抄 */ + const dropVersion = useMutation({ + mutationFn: (version: number) => + call<{ packages: ProcessPackage[] }>(deleteProcessVersionRoute, { + params: { id: pkg.id, version: String(version) }, + }), + onSuccess: (out) => { + void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] }); + setAsk(undefined); + // 删掉的正是在看的这一版 → 落到还剩的最新一版;一版不剩就回货架 + const rest = out.packages; + if (rest.length === 0) nav.jump('workflows', 'pkgs'); + else if (rest[0] !== undefined) onSaved(rest[0].version); + }, + onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '删不掉'), + }); + + /** 改名:名字是这条流的身份 —— 一次改及于所有版本 */ + const rename = useMutation({ + mutationFn: (name: string) => + call<{ packages: ProcessPackage[] }>(renameProcessRoute, { params: { id: pkg.id }, body: { name } }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] }); + setRenaming(false); + }, + onError: (e: unknown) => setSaveErr((e as { message?: string }).message ?? '改名失败'), + }); + /** * 新建版本 = **以当前看的版本为底克隆一版**(服务端定版号,**不生效**)。 * 与「激活」分工明确:激活只是把生效指针切过去,不产生新版本; @@ -538,6 +532,23 @@ function FlowLab({ }, }); + const askDrop = (v: ProcessPackage) => + setAsk({ + danger: true, + spec: { + title: `删除 v${v.version}:${pkg.name}`, + note: ( + + 版本删了**不可撤销**。生效版与**在途钉版**删不掉 —— 后者那些单要按这一版跑完, + 定义没了就再也推不动。 + + ), + impact: { who: '这一版的定义不再存在', undo: '不可撤销', when: '确认即删' }, + okLabel: `删掉 v${v.version}`, + onOk: () => dropVersion.mutate(v.version), + }, + }); + const askRetire = (v: ProcessPackage) => setAsk({ danger: true, @@ -652,12 +663,12 @@ function FlowLab({ {/* 编排工作区:左 1/3 版本列表 + 右 2/3 画布 —— 版本收在流程内部,不平铺台面 */}
-
diff --git a/docs/产品设计.md b/docs/产品设计.md index 3c33a3c..642d99b 100644 --- a/docs/产品设计.md +++ b/docs/产品设计.md @@ -63,7 +63,7 @@ |---|---|---| | **蓝图** | 全局 —— 定义完了没有 | 四库因果链 + 待补清单(见上) | | **触发器** | 谁调起流程 | 事件 / 登记 / 周期三类调起规则的定义;定完就地投一封信演练,看哪条规则接住(归属解析 → 三级消费的真链路)。**启停开关在运营中心** | -| **流程包** | 战略 —— 企业以哪些流转运转 | 编排工作区(画布 + 属性卡)、版本谱系、导出依赖文件。**边只表达顺序与条件**(对齐 Mastra):数据按输入/输出字段名流动,约束力在两端活动契约上;节点属性卡的「数据接口」列出每个输入字段运行时从哪来(起单 / 上游同名 / 补全单) | +| **流程包** | 战略 —— 企业以哪些流转运转 | 编排工作区**三栏**:左窄轨版本(激活/停用 · 改名 · 删除)、中流程图、右节点属性。**边只表达顺序与条件**(对齐 Mastra),数据按字段名流动;**字段只声明,不做任何要求** —— 激活闸只剩结构闭合与活动装载两面,字段够不够是活动自己的事 | | **活动库** | 方法 —— 每件事怎么干 | 货架、契约面、范式、资产版本谱系、安装两审 | | **工具箱** | 能力 —— 系统能对世界做什么 | 功能 / LLM / 人类三类工具目录、白名单授权、幂等口径、引用反查。**一律主显示中文名**,逻辑名退居副标 | | **数据域** | 对象 —— 企业运营什么 | 域与对象 schema、字段、放行规则、被谁引用。**行数据不在这里** —— 那是运营的事 | diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index 5a98fe0..2667f6f 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -226,6 +226,30 @@ export const setProcessStateRoute = { output: z.object({ packages: z.array(processPackageSchema) }), } as const satisfies RouteContract; +/** + * 删一版 —— 两道闸(2026-07-30): + * · **生效版不可删**:先停用,免得手滑把正在起单的定义删了; + * · **在途钉版不可删**:那些单要按它跑完,定义没了就再也推不动。 + */ +export const deleteProcessVersionRoute = { + method: 'DELETE', + path: '/api/gov/process/:id/:version', + params: z.object({ id: z.string(), version: z.string() }), + output: z.object({ packages: z.array(processPackageSchema) }), +} as const satisfies RouteContract; + +/** + * 改名 —— 名字是**这条流的身份**,不是某一版的属性:一次改及于它的所有版本。 + * (版本列表上只显示 v1/v2…,若只改一版的名,列表上什么也看不见,人会以为没生效。) + */ +export const renameProcessRoute = { + method: 'POST', + path: '/api/gov/process/:id/rename', + params: z.object({ id: z.string() }), + body: z.object({ name: z.string().min(1).max(40) }), + output: z.object({ packages: z.array(processPackageSchema) }), +} as const satisfies RouteContract; + /** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */ export const validateProcessRoute = { @@ -388,7 +412,14 @@ export const listActivitiesRoute = { contract: z.object({ requiredInputs: z.array(z.string()), inputFields: z.array(z.object({ name: z.string(), type: z.string(), required: z.boolean() })), - outputPorts: z.array(z.object({ name: z.string(), description: z.string().optional(), fields: z.array(z.string()) })), + outputPorts: z.array( + z.object({ + name: z.string(), + description: z.string().optional(), + /** 字段名 + 类型;**数组写成 `T[]`**(如 `object[]`)—— 声明得看得出是不是一串 */ + fields: z.array(z.object({ name: z.string(), type: z.string() })), + }), + ), tools: z.array(z.string()), models: z.array(z.string()), objects: z.array(z.string()), diff --git a/packages/contracts/src/process-package.ts b/packages/contracts/src/process-package.ts index 7acdd55..d91dd2d 100644 --- a/packages/contracts/src/process-package.ts +++ b/packages/contracts/src/process-package.ts @@ -232,14 +232,15 @@ export type ProcessPackage = z.infer; /* ── 编排校验的报告形状(前端画布即时预览与后端裁决同源) ───────────── */ +/** + * 激活闸只有两面(2026-07-30 用户定「字段只声明,不做任何要求」): + * **结构闭合**(dangling-node / multi-default-edge)与**活动装载**(unknown-activity)。 + * 字段类的检查全部退役 —— 要什么、够不够、类型对不对归活动自己,不在编排层现形。 + */ export const CLOSURE_VIOLATION_KINDS = [ - /** 相邻两活动同名字段类型相抵 —— schema 咬合是唯一的数据约束,名同型异必坏 */ - 'type-conflict', 'dangling-node', /** 多出边的无条件默认兜底超过一条 —— 第二条默认永远走不到 */ 'multi-default-edge', - /** 容忍分支的产出被下游必填依赖 —— 分支可空,依赖它的必填就会空 */ - 'tolerate-required-dependency', 'unknown-activity', ] as const; export const closureViolationKindSchema = z.enum(CLOSURE_VIOLATION_KINDS);