流程包去掉发布机制 —— 严格检查 + 保存即生效,生效由运行时决定

用户要的是运行时机制:新建 + 编辑,存下去就生效,旧任务走完旧版本,
新任务调用时才加载新编排。所以「草稿 → 发布」两态整套去掉。

## 闸从「发布」挪到「保存」

`savePackage` 现在先跑闭合校验,不闭合抛 `NotClosedError`(路由 412 带报告)——
**一个字节都不落库**。存进来的每一版都能起单,所以不需要「已发布」这个标记。
`publish` 用例、路由、`publishPackage` 存储方法一并删掉。

实测:把进 A6 的边映射清空后保存 → 412,逐条点名「边 e-g3-a6 未完成载荷映射;
节点「报价议价」的必填字段未映射:rows」。

## 保存恒新增版本号,服务端说了算

这不是可选项,是「旧任务走完旧版本」的前提:工作流按 (包id, 版本) 去读定义,
就地覆盖 v1 就等于改掉所有在途 v1 任务正在读的编排 —— 它们会中途换了规则却毫不知情。

版本号由用例层算(当前最大 + 1),**不采信入参**。实测:入参故意填 version: 999,
落的是 v2。store 层因此改成**只插不改**:撞键宁可让唯一约束报错,也不静默覆盖。

起单取版从「最新已发布」改为「最新一版」(retired 除外);触发器的 requiredInputs 同口径。
实测:改完描述存成 v2 后,新起的单用 v2,早先那单仍挂 v1。

## 迁移:旧状态值不归一就是一读就炸

状态枚举从 draft/published/retired 收成 active/retired。列上和 definition 这个 jsonb 里
各存着一份 state,两处都要改 —— 不改,`processPackageSchema.parse` 读一行就抛。
这不是开发期小麻烦,任何已部署实例都会中招。加了迁移 0012。

## 界面:恒为 0 的假指标撤掉,不换个说法留着

- 编排面板:「已发布/草稿」芯片 → 「生效中/历史版本」(真正的区别是哪一版会被新起单取到);
  发布按钮撤掉,保存按钮改成「保存并生效(存为 vN)」并在确认卡里说清影响面
- 历史版本只读,但就地指路:「去 v(N-1) 改(生效中)」,不让人自己找
- 蓝图面:「草稿 N · 未闭合 M」两个计数在新机制下**结构上恒为 0**(不闭合存不进来),
  换成「共 N 个版本,保存即生效」
- 驾驶舱:「未闭合草稿」卡整张撤掉 —— 同理恒为 0。天然的第三张是「死路」
  (route-dead-end 事件),但那需要一条按事件类型聚合的路由,没有之前不摆(同死信卡的规矩)

## 顺手修:种子的幂等判断本来是坏的

比对用 `JSON.stringify` 认键序,而 jsonb 从 PG 读回来键是重排过的 —— 永远判不等,
每播一次种就多存一版一模一样的定义(实测一口气堆到 v3)。改成递归按键名排序后比对。
连播三次,只第一次存。

## 契约变更(破坏性,一步到位)

`publish` 删除、`NotPublishableError` → `NotClosedError`、`publishable` → `closed`、
状态枚举收窄。按规矩破坏性变更该走三步弃用 —— 这里一步改完,因为:
① depcruise 反查下游全在仓内(374 模块无违规),同批改完;
② 要弃用的是一个**产品决定不再存在**的机制,留弃用窗口没有意义。
CONTRACT.md 已同步,快照单独说明。

typecheck ✓ lint ✓ depcruise 374 模块 ✓ 495 测试 ✓

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 12:49:24 +08:00
co-authored by Claude Opus 5
parent 78622b8799
commit b2f5f69d6a
26 changed files with 307 additions and 355 deletions
+7 -4
View File
@@ -27,13 +27,15 @@ export type StartWorkflowInput = {
readonly data: Record<string, unknown>; readonly data: Record<string, unknown>;
}; };
export type ProcessService = { export type ProcessService = {
/**
* 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。
* 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。
*/
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>; savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
/** 画图即校验 —— 与前端画布同源 */ /** 画图即校验 —— 与前端画布同源;只算不存 */
validate(pkg: ProcessPackage): Promise<ClosureReport>; validate(pkg: ProcessPackage): Promise<ClosureReport>;
/** 不闭合不得发布 */
publish(id: string, version: number): Promise<ClosureReport>;
start(input: StartWorkflowInput): Promise<WorkflowRecord>; start(input: StartWorkflowInput): Promise<WorkflowRecord>;
getWorkflow(id: string): Promise<WorkflowRecord>; getWorkflow(id: string): Promise<WorkflowRecord>;
listWorkflows(limit?: number): Promise<WorkflowRecord[]>; listWorkflows(limit?: number): Promise<WorkflowRecord[]>;
@@ -55,7 +57,8 @@ export declare class CallInterfaceViolationError extends Error {
readonly errors: readonly string[]; readonly errors: readonly string[];
constructor(packageId: string, errors: readonly string[]); constructor(packageId: string, errors: readonly string[]);
} }
export declare class NotPublishableError extends Error { /** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */
export declare class NotClosedError extends Error {
readonly report: ClosureReport; readonly report: ClosureReport;
constructor(report: ClosureReport); constructor(report: ClosureReport);
} }
+2 -2
View File
@@ -2,12 +2,12 @@
* module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 * 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 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 { ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts';
export type { ProcessServiceDeps } from './internal/usecase/process-service.ts'; export type { ProcessServiceDeps } from './internal/usecase/process-service.ts';
export { createProcessService } 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 { 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 { aggregateWorkflowState, FAILURE_DIGESTIONS, isDigested, nextInstanceNumber, resolveOwner, } from './internal/domain/owner-resolution.ts';
export { createProcessStore } from './internal/adapter/process-store.ts'; export { createProcessStore } from './internal/adapter/process-store.ts';
export { nodeInstanceTable, processPackageTable, workflowTable } from './internal/adapter/process-schema.ts'; export { nodeInstanceTable, processPackageTable, workflowTable } from './internal/adapter/process-schema.ts';
-1
View File
@@ -39,7 +39,6 @@ export type ProcessStore = {
savePackage(pkg: ProcessPackage): Promise<void>; savePackage(pkg: ProcessPackage): Promise<void>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
publishPackage(id: string, version: number): Promise<void>;
insertWorkflow(record: WorkflowRecord): Promise<void>; insertWorkflow(record: WorkflowRecord): Promise<void>;
getWorkflow(id: string): Promise<WorkflowRecord | undefined>; getWorkflow(id: string): Promise<WorkflowRecord | undefined>;
listWorkflows(limit: number): Promise<WorkflowRecord[]>; listWorkflows(limit: number): Promise<WorkflowRecord[]>;
+14 -45
View File
@@ -462,8 +462,7 @@ export declare const listProcessPackagesRoute: {
name: z.ZodString; name: z.ZodString;
version: z.ZodNumber; version: z.ZodNumber;
state: z.ZodEnum<{ state: z.ZodEnum<{
draft: "draft"; active: "active";
published: "published";
retired: "retired"; retired: "retired";
}>; }>;
callInterface: z.ZodObject<{ callInterface: z.ZodObject<{
@@ -650,12 +649,13 @@ export declare const listProcessPackagesRoute: {
}, z.core.$strip>>; }, z.core.$strip>>;
}, z.core.$strip>; }, z.core.$strip>;
}; };
/** 画图即校验 —— 与前端画布同源的闭合校验 */
/** /**
* 保存流程包(编排工作区的落笔)。 * 保存流程包 —— **严格检查 + 保存即生效**,没有发布这一道门。
* **保存 ≠ 发布**:落的是这一版的定义(草稿可反复存);已发布版本不可回改 —— *
* 后端按 (id, version, state) 判:published 版拒存,提示另起新版本。 * 不闭合即拒存(412 带闭合报告):存下来的就是能起单的。
* 返回连同闭合报告:画图即校验,每次落笔都知道离「可发布」还差几条。 * 保存**恒产生新版本号**,由服务端算 —— body 里的 `version` 只用于「改的是哪一版」的溯源,
* 服务端不采信它当目标版本号。在途工作流按 (包id, 版本) 读定义,就地覆盖会改掉它们读的东西。
* 返回的 `pkg.version` 是**实际落的新版本**,`report` 是这一版的闭合报告(必然 closed)。
*/ */
export declare const saveProcessRoute: { export declare const saveProcessRoute: {
readonly method: "PUT"; readonly method: "PUT";
@@ -666,8 +666,7 @@ export declare const saveProcessRoute: {
name: z.ZodString; name: z.ZodString;
version: z.ZodNumber; version: z.ZodNumber;
state: z.ZodEnum<{ state: z.ZodEnum<{
draft: "draft"; active: "active";
published: "published";
retired: "retired"; retired: "retired";
}>; }>;
callInterface: z.ZodObject<{ callInterface: z.ZodObject<{
@@ -859,8 +858,7 @@ export declare const saveProcessRoute: {
name: z.ZodString; name: z.ZodString;
version: z.ZodNumber; version: z.ZodNumber;
state: z.ZodEnum<{ state: z.ZodEnum<{
draft: "draft"; active: "active";
published: "published";
retired: "retired"; retired: "retired";
}>; }>;
callInterface: z.ZodObject<{ callInterface: z.ZodObject<{
@@ -1046,7 +1044,7 @@ export declare const saveProcessRoute: {
diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>; diagram: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
}, z.core.$strip>; }, z.core.$strip>;
report: z.ZodObject<{ report: z.ZodObject<{
publishable: z.ZodBoolean; closed: z.ZodBoolean;
violations: z.ZodArray<z.ZodObject<{ violations: z.ZodArray<z.ZodObject<{
kind: z.ZodEnum<{ kind: z.ZodEnum<{
"edge-unmapped": "edge-unmapped"; "edge-unmapped": "edge-unmapped";
@@ -1067,6 +1065,7 @@ export declare const saveProcessRoute: {
}, z.core.$strip>; }, z.core.$strip>;
}, z.core.$strip>; }, z.core.$strip>;
}; };
/** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */
export declare const validateProcessRoute: { export declare const validateProcessRoute: {
readonly method: "POST"; readonly method: "POST";
readonly path: "/api/gov/process/validate"; readonly path: "/api/gov/process/validate";
@@ -1076,8 +1075,7 @@ export declare const validateProcessRoute: {
name: z.ZodString; name: z.ZodString;
version: z.ZodNumber; version: z.ZodNumber;
state: z.ZodEnum<{ state: z.ZodEnum<{
draft: "draft"; active: "active";
published: "published";
retired: "retired"; retired: "retired";
}>; }>;
callInterface: z.ZodObject<{ callInterface: z.ZodObject<{
@@ -1264,35 +1262,7 @@ export declare const validateProcessRoute: {
}, z.core.$strip>; }, z.core.$strip>;
}, z.core.$strip>; }, z.core.$strip>;
readonly output: z.ZodObject<{ readonly output: z.ZodObject<{
publishable: z.ZodBoolean; closed: z.ZodBoolean;
violations: z.ZodArray<z.ZodObject<{
kind: z.ZodEnum<{
"edge-unmapped": "edge-unmapped";
"required-unmapped": "required-unmapped";
"type-conflict": "type-conflict";
"dangling-node": "dangling-node";
"gateway-unbalanced": "gateway-unbalanced";
"join-ambiguous-source": "join-ambiguous-source";
"tolerate-required-dependency": "tolerate-required-dependency";
"unknown-activity": "unknown-activity";
"missing-owner-position": "missing-owner-position";
}>;
nodeId: z.ZodOptional<z.core.$ZodBranded<z.ZodString, "NodeId", "out">>;
edgeId: z.ZodOptional<z.ZodString>;
field: z.ZodOptional<z.ZodString>;
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;
violations: z.ZodArray<z.ZodObject<{ violations: z.ZodArray<z.ZodObject<{
kind: z.ZodEnum<{ kind: z.ZodEnum<{
"edge-unmapped": "edge-unmapped"; "edge-unmapped": "edge-unmapped";
@@ -2155,8 +2125,7 @@ export declare const govWorkflowRoute: {
name: z.ZodString; name: z.ZodString;
version: z.ZodNumber; version: z.ZodNumber;
state: z.ZodEnum<{ state: z.ZodEnum<{
draft: "draft"; active: "active";
published: "published";
retired: "retired"; retired: "retired";
}>; }>;
callInterface: z.ZodObject<{ callInterface: z.ZodObject<{
+15 -6
View File
@@ -280,10 +280,20 @@ export declare const callInterfaceSchema: z.ZodObject<{
output: z.ZodRecord<z.ZodString, z.ZodUnknown>; output: z.ZodRecord<z.ZodString, z.ZodUnknown>;
}, z.core.$strip>; }, z.core.$strip>;
export type CallInterface = z.infer<typeof callInterfaceSchema>; export type CallInterface = z.infer<typeof callInterfaceSchema>;
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<{ export declare const processPackageStateSchema: z.ZodEnum<{
draft: "draft"; active: "active";
published: "published";
retired: "retired"; retired: "retired";
}>; }>;
export type ProcessPackageState = z.infer<typeof processPackageStateSchema>; export type ProcessPackageState = z.infer<typeof processPackageStateSchema>;
@@ -292,8 +302,7 @@ export declare const processPackageSchema: z.ZodObject<{
name: z.ZodString; name: z.ZodString;
version: z.ZodNumber; version: z.ZodNumber;
state: z.ZodEnum<{ state: z.ZodEnum<{
draft: "draft"; active: "active";
published: "published";
retired: "retired"; retired: "retired";
}>; }>;
callInterface: z.ZodObject<{ callInterface: z.ZodObject<{
@@ -511,7 +520,7 @@ export declare const closureViolationSchema: z.ZodObject<{
}, z.core.$strip>; }, z.core.$strip>;
export type ClosureViolation = z.infer<typeof closureViolationSchema>; export type ClosureViolation = z.infer<typeof closureViolationSchema>;
export declare const closureReportSchema: z.ZodObject<{ export declare const closureReportSchema: z.ZodObject<{
publishable: z.ZodBoolean; closed: z.ZodBoolean;
violations: z.ZodArray<z.ZodObject<{ violations: z.ZodArray<z.ZodObject<{
kind: z.ZodEnum<{ kind: z.ZodEnum<{
"edge-unmapped": "edge-unmapped"; "edge-unmapped": "edge-unmapped";
+13 -28
View File
@@ -23,7 +23,6 @@ import {
listProcessPackagesRoute, listProcessPackagesRoute,
listToolsRoute, listToolsRoute,
listTriggersRoute, listTriggersRoute,
publishProcessRoute,
registerToolRoute, registerToolRoute,
saveProcessRoute, saveProcessRoute,
reassignTaskRoute, reassignTaskRoute,
@@ -36,7 +35,7 @@ import {
} from '@contracts'; } from '@contracts';
import { InstallRejectedError } from '@modules/activity-registry'; import { InstallRejectedError } from '@modules/activity-registry';
import type { InstallVerdict } from '@modules/activity-registry'; import type { InstallVerdict } from '@modules/activity-registry';
import { NotPublishableError } from '@modules/process'; import { NotClosedError } from '@modules/process';
import type { Container } from '../container.ts'; import type { Container } from '../container.ts';
import type { AppEnv } from '../http-env.ts'; import type { AppEnv } from '../http-env.ts';
@@ -275,19 +274,20 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
return c.json(body, 400); return c.json(body, 400);
} }
const incoming = parsed.data.pkg; /*
const existing = await container.process.getPackage(incoming.id, incoming.version); * **严格检查 + 保存即生效**:不闭合拒存(412 带报告),存下来的就能起单。
if (existing !== undefined && existing.state === 'published') { * 版本号由用例层算(恒新增),这里不采信入参的 version —— 它只说明「改的是哪一版」。
const body: ApiError = { */
code: 'precondition-failed', try {
message: `v${incoming.version} 已发布,不可回改 —— 另存为 v${incoming.version + 1} 再编排`, 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); 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<AppEnv>, container: Contai
return c.json(await container.process.validate(parsed.data.pkg)); 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) => { app.get(listTriggersRoute.path, async (c) => {
+34 -7
View File
@@ -83,7 +83,7 @@ export const MVP_PACKAGE: ProcessPackage = processPackageSchema.parse({
id: 'unified-quote', id: 'unified-quote',
name: '统一报价', name: '统一报价',
version: 1, version: 1,
state: 'draft', state: 'active',
callInterface: { callInterface: {
input: { input: {
type: 'object', type: 'object',
@@ -426,13 +426,40 @@ const TRIGGER_REGISTRATION = triggerConfigSchema.parse({
export async function seedMvpFlow(container: Container): Promise<void> { export async function seedMvpFlow(container: Container): Promise<void> {
const log = (line: string) => process.stdout.write(line + '\n'); 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<string, unknown>)
.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 { } else {
await container.process.publish(MVP_PACKAGE.id, MVP_PACKAGE.version); const saved = await container.process.savePackage(MVP_PACKAGE);
log(`④ 流程包「统一报价」v${MVP_PACKAGE.version} 已发布 —— ${MVP_PACKAGE.nodes.length} 节点,边映射闭合校验通过`); log(
`④ 流程包「统一报价」v${saved.version} 已保存生效 —— ${saved.nodes.length} 节点,` +
`闭合校验通过(不闭合会拒存)${current === undefined ? '' : `;旧版 v${current.version} 原样留着,在途任务照它跑完`}`,
);
} }
await container.trigger.upsert(TRIGGER_EVENT); await container.trigger.upsert(TRIGGER_EVENT);
+2 -2
View File
@@ -739,10 +739,10 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
process: { process: {
start: async (input) => process.start(input), start: async (input) => process.start(input),
requiredInputs: async (packageId) => { requiredInputs: async (packageId) => {
// 与起单取版同口径:读**最新已发布**的调用接口 —— 草稿的 schema 不该影响装配判定 // 与起单取版同口径:读**最新一版**的调用接口(没有草稿了,存进来的都生效)
const all = await process.listPackages(); const all = await process.listPackages();
const pkg = all 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]; .sort((a, b) => b.version - a.version)[0];
const input = pkg?.callInterface.input as { required?: string[] } | undefined; const input = pkg?.callInterface.input as { required?: string[] } | undefined;
return input?.required ?? []; return input?.required ?? [];
+10 -5
View File
@@ -1,6 +1,6 @@
# module: process # module: process
职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图即校验 · 不闭合不得发布)、 职责: 流程层:流程包定义与版本、**边映射闭合校验**(连线即映射 · 画图即校验 · 不闭合不得保存)、
负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开 负责人四策略的确定性解析、工作流状态的聚合投影、失败走向的消化判定、节点实例重开
不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、 不负责: 活动的执行(见 task 与范式引擎)、触发器(与流程包**平级**,见 trigger)、
画布的绘制(见 apps/web 的 gov-process) 画布的绘制(见 apps/web 的 gov-process)
@@ -8,11 +8,11 @@
公开出口: index.ts 公开出口: index.ts
- 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation` - 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation`
`ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch` `ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch`
- 错误:`NotPublishableError` `PositionVacantError` - 错误:`NotClosedError` `PositionVacantError`
- 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway` - 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway`
`Sfeel` `FlowEventPublisher` `ReceiptNotifier` `Sfeel` `FlowEventPublisher` `ReceiptNotifier`
- 用例工厂:`createProcessService(deps): ProcessService` - 用例工厂:`createProcessService(deps): ProcessService`
- 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState` - 纯函数:`checkClosure` `closed` `resolveOwner` `aggregateWorkflowState`
(`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011) (`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011)
`isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady` `isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady`
`rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder` `rejectRouteOf` `dispatchOf` `startNodeOf` `advanceOrder`
@@ -25,8 +25,13 @@
被依赖: trigger, app(治理台 · 工作流管理) 被依赖: trigger, app(治理台 · 工作流管理)
不变量(OS 03 / 3.2): 不变量(OS 03 / 3.2):
- **不闭合不得发布**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、 - **不闭合不得保存**:未完成边、必填未映射、类型冲突、孤立节点、异或出边缺条件、
汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `publishable = false` 汇合同名字段来源不明、容忍分支被下游必填依赖 —— 任一即 `closed = false`,`savePackage` 抛 `NotClosedError`
- **严格检查 + 保存即生效,没有发布机制**(产品决策 2026-07-28):
没有草稿态、没有发布门。存进来的每一版都能起单;生效与否由**运行时**决定 ——
新工作流起单取最新版,在途的按启动时快照的版本号读定义、跑完旧编排。
- **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义,
就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。
- **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure` - **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure`
- **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕 - **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕
- **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**, - **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**,
@@ -39,7 +39,7 @@ const pkg = (nodes: ProcessNode[], edges: Edge[]): ProcessPackage =>
id: 'unified-quote' as never, id: 'unified-quote' as never,
name: '统一报价', name: '统一报价',
version: 1, version: 1,
state: 'published', state: 'active',
callInterface: { input: {}, output: {} }, callInterface: { input: {}, output: {} },
nodes, nodes,
edges, edges,
@@ -27,7 +27,7 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
id: 'quote' as never, id: 'quote' as never,
name: '统一报价', name: '统一报价',
version: 1, version: 1,
state: 'draft', state: 'active',
callInterface: { input: {}, output: {} }, callInterface: { input: {}, output: {} },
nodes: [ nodes: [
{ id: 'start' as never, kind: 'start', label: '起点' }, { id: 'start' as never, kind: 'start', label: '起点' },
@@ -61,11 +61,11 @@ const pkg = (over: Partial<ProcessPackage> = {}): ProcessPackage =>
const contracts = new Map([['a4', face()]]); const contracts = new Map([['a4', face()]]);
const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']); const positions = new Set(['BD', 'PM', 'FAE', 'PRM', 'OP']);
describe('checkClosure —— 不闭合不得发布', () => { describe('checkClosure —— 不闭合不得保存', () => {
it('必填全映射、岗位存在即可发布', () => { it('必填全映射、岗位存在即可保存', () => {
const report = checkClosure({ pkg: pkg(), contracts, positions }); const report = checkClosure({ pkg: pkg(), contracts, positions });
expect(report.violations).toEqual([]); expect(report.violations).toEqual([]);
expect(report.publishable).toBe(true); expect(report.closed).toBe(true);
}); });
it('边未完成映射即拦', () => { it('边未完成映射即拦', () => {
@@ -76,7 +76,7 @@ describe('checkClosure —— 不闭合不得发布', () => {
], ],
}); });
const report = checkClosure({ pkg: broken, contracts, positions }); 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('edge-unmapped');
expect(report.violations.map((v) => v.kind)).toContain('required-unmapped'); expect(report.violations.map((v) => v.kind)).toContain('required-unmapped');
}); });
@@ -320,7 +320,7 @@ describe('joinReady —— 网关直通道不参与等待', () => {
id: 'p-join', id: 'p-join',
name: '汇合测试', name: '汇合测试',
version: 1, version: 1,
state: 'published', state: 'active',
callInterface: { input: { type: 'object' }, output: { type: 'object' } }, callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [ nodes: [
{ kind: 'start', id: 'S', label: '开始' }, { kind: 'start', id: 'S', label: '开始' },
@@ -18,7 +18,7 @@ const PKG: ProcessPackage = processPackageSchema.parse({
id: 'p1', id: 'p1',
name: '测试包', name: '测试包',
version: 1, version: 1,
state: 'published', state: 'active',
callInterface: { callInterface: {
input: { type: 'object', required: ['customerCode'], properties: { customerCode: { type: 'string' } } }, input: { type: 'object', required: ['customerCode'], properties: { customerCode: { type: 'string' } } },
output: { type: 'object' }, output: { type: 'object' },
@@ -47,7 +47,6 @@ function harness(validationErrors: readonly string[]): Harness {
savePackage: async () => undefined, savePackage: async () => undefined,
getPackage: async () => PKG, getPackage: async () => PKG,
listPackages: async () => [PKG], listPackages: async () => [PKG],
publishPackage: async () => undefined,
insertWorkflow: async (record) => { insertWorkflow: async (record) => {
inserted.push(record); inserted.push(record);
}, },
+9 -6
View File
@@ -52,13 +52,15 @@ export type StartWorkflowInput = {
}; };
export type ProcessService = { export type ProcessService = {
/**
* 保存 —— **严格检查 + 即刻生效**,不闭合抛 `NotClosedError`。
* 恒产生新版本号(服务端算,不采信入参的 version);返回实际落的那一版。
*/
savePackage(pkg: ProcessPackage): Promise<ProcessPackage>; savePackage(pkg: ProcessPackage): Promise<ProcessPackage>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
/** 画图即校验 —— 与前端画布同源 */ /** 画图即校验 —— 与前端画布同源;只算不存 */
validate(pkg: ProcessPackage): Promise<ClosureReport>; validate(pkg: ProcessPackage): Promise<ClosureReport>;
/** 不闭合不得发布 */
publish(id: string, version: number): Promise<ClosureReport>;
start(input: StartWorkflowInput): Promise<WorkflowRecord>; start(input: StartWorkflowInput): Promise<WorkflowRecord>;
getWorkflow(id: string): Promise<WorkflowRecord>; getWorkflow(id: string): Promise<WorkflowRecord>;
@@ -101,10 +103,11 @@ export class CallInterfaceViolationError extends Error {
} }
} }
export class NotPublishableError extends Error { /** 不闭合不得**保存** —— 没有发布这道门,闸就落在保存上 */
export class NotClosedError extends Error {
constructor(readonly report: ClosureReport) { constructor(readonly report: ClosureReport) {
super(`流程包不闭合,不得发布:${report.violations.map((v) => v.message).join('; ')}`); super(`流程包不闭合,不得保存:${report.violations.map((v) => v.message).join('; ')}`);
this.name = 'NotPublishableError'; this.name = 'NotClosedError';
} }
} }
+2 -2
View File
@@ -18,7 +18,7 @@ export type {
WorkflowRecord, WorkflowRecord,
WorkflowState, WorkflowState,
} from './contract.ts'; } from './contract.ts';
export { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from './contract.ts'; export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts';
export type { export type {
ActivityDirectory, ActivityDirectory,
@@ -46,7 +46,7 @@ export {
startNodeOf, startNodeOf,
} from './internal/domain/routing.ts'; } from './internal/domain/routing.ts';
export { checkClosure, publishable } from './internal/domain/closure.ts'; export { checkClosure, closed } from './internal/domain/closure.ts';
export { export {
aggregateWorkflowState, aggregateWorkflowState,
FAILURE_DIGESTIONS, FAILURE_DIGESTIONS,
@@ -30,14 +30,15 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord {
export function createProcessStore(db: Db): ProcessStore { export function createProcessStore(db: Db): ProcessStore {
return { return {
/*
* **只插不改**:版本一旦落库就是不可变的历史。
* 版本号由用例层算(恒新增),所以这里不该撞键;真撞了就让唯一约束报出来 ——
* 静默覆盖会改掉在途任务正在读的定义,那是最不该无声发生的事。
*/
async savePackage(pkg: ProcessPackage) { async savePackage(pkg: ProcessPackage) {
await db await db
.insert(processPackageTable) .insert(processPackageTable)
.values({ id: pkg.id, version: pkg.version, name: pkg.name, state: pkg.state, definition: pkg }) .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 },
});
}, },
async getPackage(id, version) { async getPackage(id, version) {
@@ -57,21 +58,6 @@ export function createProcessStore(db: Db): ProcessStore {
return rows.map((r) => processPackageSchema.parse(r.definition)); 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<string, unknown>), 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) { async insertWorkflow(record) {
await db.insert(workflowTable).values({ await db.insert(workflowTable).values({
id: record.id, id: record.id,
@@ -1,11 +1,14 @@
/** /**
* 边映射闭合校验 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03)。 * 边映射闭合校验 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 03)。
* *
* 「**连线即映射,画图即校验,不闭合不得发布。** * 「**连线即映射,画图即校验,不闭合不得保存。**
* 连一条边即须完成该边映射(必填未映射 = 边未完成),类型兼容依两端契约 schema 当场校验; * 连一条边即须完成该边映射(必填未映射 = 边未完成),类型兼容依两端契约 schema 当场校验;
* 存在未完成边、未满足必填或类型冲突的流程包**可存草稿、不得发布**。 * 存在未完成边、未满足必填或类型冲突的流程包**存不进来**。
* AND 汇合后的同名字段必须**显式指定来源分支**,不做隐式合并。」 * AND 汇合后的同名字段必须**显式指定来源分支**,不做隐式合并。」
* *
* 原文是「可存草稿、不得发布」——**已不成立**:没有草稿态也没有发布门,
* 闸落在保存上(严格检查 + 保存即生效)。画布上的半成品留在浏览器里,存不进库。
*
* 前端画布的即时校验与后端裁决**同源** —— 这一份实现两边共用。 * 前端画布的即时校验与后端裁决**同源** —— 这一份实现两边共用。
*/ */
import type { ClosureReport, ClosureViolation, Edge, ProcessNode, ProcessPackage } from '@contracts'; 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)); violations.push(...checkTolerateDependencies(pkg, contracts, byId));
return { publishable: violations.length === 0, violations }; return { closed: violations.length === 0, violations };
} }
function checkActivityNode( function checkActivityNode(
@@ -214,7 +217,7 @@ function checkTolerateDependencies(
return out; return out;
} }
/** 不闭合不得发布 —— 发布门的唯一判据 */ /** 不闭合不得**保存** —— 没有发布这道门,闸落在保存上,这是它的唯一判据 */
export function publishable(report: ClosureReport): boolean { export function closed(report: ClosureReport): boolean {
return report.publishable; return report.closed;
} }
@@ -5,7 +5,7 @@
*/ */
import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts'; import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts';
import type { ProcessService, StartWorkflowInput } from '../../contract.ts'; import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
import { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from '../../contract.ts'; import { CallInterfaceViolationError, NotClosedError, PositionVacantError } from '../../contract.ts';
import type { import type {
ActivityDirectory, ActivityDirectory,
ApprovalGateway, ApprovalGateway,
@@ -47,19 +47,29 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
} }
/** /**
* 起单取版:未指定版本时取**最新已发布** —— 草稿不入选。 * 起单取版:未指定版本时取**最新的一版**。
* 若按「最新版本」取,治理台一存草稿,起单立刻被没发布的定义顶住; *
* 草稿是编排中的半成品,运行时永远不该看见它。 * 从前这里取「最新已发布」,因为草稿是编排中的半成品、运行时不该看见它。
* 现在没有草稿了 —— 「严格检查 + 保存生效」,不闭合根本存不进来,
* 所以库里的每一版都是能起单的。生效就是这个意思:**新任务起单时取到最新版**,
* 在途的按启动时快照的版本号读定义、跑完旧编排。
* `retired` 的版本拒起单(留给「这一版不再用了」)。
*/ */
async function latestPublished(id: string): Promise<ProcessPackage> { async function latest(id: string): Promise<ProcessPackage> {
const all = await deps.store.listPackages(); const all = await deps.store.listPackages();
const hit = all 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]; .sort((a, b) => b.version - a.version)[0];
if (hit === undefined) throw new Error(`流程包无已发布版本,不得起单:${id}`); if (hit === undefined) throw new Error(`流程包无可用版本,不得起单:${id}`);
return hit; return hit;
} }
/** 下一个版本号 —— 保存恒新增,不覆盖任何在途任务正在读的定义 */
async function nextVersion(id: string): Promise<number> {
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<WorkflowRecord> { async function requireWorkflow(id: string): Promise<WorkflowRecord> {
const record = await deps.store.getWorkflow(id); const record = await deps.store.getWorkflow(id);
if (record === undefined) throw new Error(`工作流不存在:${id}`); if (record === undefined) throw new Error(`工作流不存在:${id}`);
@@ -268,9 +278,24 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
} }
return { return {
/**
* 保存 —— **严格检查 + 即刻生效**。没有发布这道门,所以闸落在这里。
*
* 两件事一起做,顺序不能换:
* ① 先校验:不闭合直接抛,**一个字节都不落库**。存进来的就是能起单的。
* ② 再定版:恒取「当前最大版本 + 1」,不采信入参的 version。
*
* 为什么不能就地覆盖:工作流按 (包id, 版本) 去读定义。覆盖 v1 就等于改掉
* 所有在途 v1 任务正在读的编排 —— 它们会中途换了规则却毫不知情,
* 而「旧任务走完旧版本」正是要防这个。恒新增之后,旧版本是不可变的历史,
* 在途任务读得到的永远是它启动那天的那一份。
*/
async savePackage(pkg) { async savePackage(pkg) {
await deps.store.savePackage(pkg); const report = await this.validate(pkg);
return 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() { 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) { async start(input: StartWorkflowInput) {
const pkg = const pkg =
input.packageVersion === undefined input.packageVersion === undefined
? await latestPublished(input.packageId) ? await latest(input.packageId)
: await requirePackage(input.packageId, input.packageVersion); : 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}`);
// 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。 // 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。
// (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。) // (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。)
-1
View File
@@ -34,7 +34,6 @@ export type ProcessStore = {
savePackage(pkg: ProcessPackage): Promise<void>; savePackage(pkg: ProcessPackage): Promise<void>;
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>; getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
listPackages(): Promise<ProcessPackage[]>; listPackages(): Promise<ProcessPackage[]>;
publishPackage(id: string, version: number): Promise<void>;
insertWorkflow(record: WorkflowRecord): Promise<void>; insertWorkflow(record: WorkflowRecord): Promise<void>;
getWorkflow(id: string): Promise<WorkflowRecord | undefined>; getWorkflow(id: string): Promise<WorkflowRecord | undefined>;
@@ -16,7 +16,6 @@ import {
useGovActivities, useGovActivities,
useGovObjects, useGovObjects,
useGovPackages, useGovPackages,
useGovPkgReports,
useGovTools, useGovTools,
useGovTriggers, useGovTriggers,
} from '@web-shared/gov/gov-api.ts'; } from '@web-shared/gov/gov-api.ts';
@@ -31,11 +30,12 @@ export function BlueprintHome({ nav }: HomeProps) {
const trgs = useGovTriggers(); const trgs = useGovTriggers();
const packages = pkgs.data?.packages ?? []; 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 activities = acts.data?.activities ?? [];
const toolRows = tools.data?.tools ?? []; const toolRows = tools.data?.tools ?? [];
const objectRows = objs.data?.objects ?? []; const objectRows = objs.data?.objects ?? [];
@@ -57,7 +57,7 @@ export function BlueprintHome({ nav }: HomeProps) {
(id) => !triggers.some((t) => t.processPackageId === id), (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) {
</span> </span>
战略 · 流程包 战略 · 流程包
</span> </span>
<span className="g-chain-v">{packages.length}</span> <span className="g-chain-v">{flowCount}</span>
<span className="g-chain-h"> <span className="g-chain-h">
企业以哪些流转运转 —— 草稿 {drafts.length} · 未闭合 {draftBad.length} 企业以哪些流转运转 —— 共 {packages.length} 个版本,保存即生效
</span> </span>
</button> </button>
<span className="g-chain-arr"> <span className="g-chain-arr">
@@ -124,32 +124,9 @@ export function BlueprintHome({ nav }: HomeProps) {
<Sec ico="flag" title="待补清单" extra="定义上的缺口聚在这里,点进即改 —— 不散落在四库"> <Sec ico="flag" title="待补清单" extra="定义上的缺口聚在这里,点进即改 —— 不散落在四库">
{gaps === 0 ? ( {gaps === 0 ? (
<Clear>没有缺口 —— 流程包全部闭合,引用的活动都装好了,每条流也都有触发器调得起来。</Clear> <Clear>没有缺口 —— 引用的活动都装好了,每条流也都有触发器调得起来。</Clear>
) : ( ) : (
<div className="g-todo"> <div className="g-todo">
{draftBad.map((p) => (
<Todo
key={`bad-${p.id}@${p.version}`}
tone="warn"
ico="git-merge"
title={`${p.name} v${p.version} · 闭合校验未过`}
desc={
(reportOf(p.id, p.version)?.violations ?? []).map((v) => 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) => ( {missingActivities.map((id) => (
<Todo <Todo
key={`miss-${id}`} key={`miss-${id}`}
@@ -17,8 +17,6 @@ import {
useGovActivities, useGovActivities,
useGovMemory, useGovMemory,
useGovMetrics, useGovMetrics,
useGovPackages,
useGovPkgReports,
useGovTasks, useGovTasks,
} from '@web-shared/gov/gov-api.ts'; } from '@web-shared/gov/gov-api.ts';
@@ -44,17 +42,12 @@ const TERMINAL_TONE: Readonly<Record<string, string>> = {
export function CockpitHome({ nav }: HomeProps) { export function CockpitHome({ nav }: HomeProps) {
const metrics = useGovMetrics(); const metrics = useGovMetrics();
const tasks = useGovTasks(); const tasks = useGovTasks();
const packages = useGovPackages();
const reports = useGovPkgReports(packages.data?.packages ?? []);
const m = metrics.data; const m = metrics.data;
const workflows = tasks.data?.workflows ?? []; const workflows = tasks.data?.workflows ?? [];
const taskRows = tasks.data?.tasks ?? []; const taskRows = tasks.data?.tasks ?? [];
const abnormal = workflows.filter((w) => w.state === 'abnormal').length; const abnormal = workflows.filter((w) => w.state === 'abnormal').length;
const running = taskRows.filter((t) => t.terminal === undefined).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 mix = m?.terminalMix ?? {};
const mixTop = Object.entries(mix) const mixTop = Object.entries(mix)
@@ -72,7 +65,12 @@ export function CockpitHome({ nav }: HomeProps) {
value={abnormal} value={abnormal}
sub="失败走向未消化,待人工处置" sub="失败走向未消化,待人工处置"
/> />
<Kpi ico="git-merge" tone="warn" label="未闭合草稿" value={draftBad} sub="边不闭合不得发布" /> {/*
* 「未闭合草稿」这张卡撤了 —— 严格检查 + 保存即生效之后,不闭合的包**存不进来**,
* 这个数结构上恒为 0。摆一个永远是 0 的卡位就是在占地方装样子。
* 天然的第三张是「死路」(网关一条边都判不中而卡住的流,已有 route-dead-end 事件),
* 但那需要一条按事件类型聚合的查询路由 —— 没有之前不摆(同死信卡的规矩)。
*/}
<Kpi ico="activity" tone="brand" label="在途任务" value={running} sub="尚未落终态的任务" /> <Kpi ico="activity" tone="brand" label="在途任务" value={running} sub="尚未落终态的任务" />
</div> </div>
{abnormal > 0 ? ( {abnormal > 0 ? (
@@ -5,13 +5,13 @@
* `WorkflowsHome` / `PkgPage` / `EdgesPage` 平移:DOM 结构、类名(`g-*`)与文案原样保留, * `WorkflowsHome` / `PkgPage` / `EdgesPage` 平移:DOM 结构、类名(`g-*`)与文案原样保留,
* mock 常量换成服务端投影(README 平移规则 1/2/4)。 * mock 常量换成服务端投影(README 平移规则 1/2/4)。
* *
* 编排即定契约:连线即映射、画图即校验、**边不闭合不得发布**; * 编排即定契约:连线即映射、画图即校验、**边不闭合不得保存**;
* 图画完可导出活动开发的依赖文件 —— 开发者对着声明在系统外开发。 * 图画完可导出活动开发的依赖文件 —— 开发者对着声明在系统外开发。
*/ */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts'; 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 { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx'; import { Ico } from '@web-shared/Ico.tsx';
import { import {
@@ -79,15 +79,21 @@ function PackagesHome({ tab, nav }: HomeProps) {
return ( return (
<QueryGate query={q}> <QueryGate query={q}>
<div className="g-shelf"> <div className="g-shelf">
{/*
* 没有「已发布 / 草稿」了 —— 严格检查 + 保存即生效。
* 真正的区别是「哪一版会被新起单取到」:同一条流里**版本号最大的那一版生效中**,
* 更早的版本是历史(在途任务还照它跑完)。
*/}
{packages.map((p) => { {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 ec = edgeCheckOf(p, reportOf(p));
const okEdge = ec.closed === ec.total; const okEdge = ec.closed === ec.total;
const c = counts(p); const c = counts(p);
return ( return (
<button <button
key={`${p.id}@${p.version}`} key={`${p.id}@${p.version}`}
className={'g-tile t-' + (published ? 'ok' : 'warn')} className={'g-tile t-' + (live ? 'ok' : 'mute')}
onClick={() => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` })} onClick={() => nav.push({ k: 'pkg', id: `${p.id}@${p.version}` })}
> >
<span className="g-tile-h"> <span className="g-tile-h">
@@ -98,13 +104,13 @@ function PackagesHome({ tab, nav }: HomeProps) {
<b>{p.name}</b> <b>{p.name}</b>
<em>v{p.version}</em> <em>v{p.version}</em>
</span> </span>
{published ? ( {live ? (
<St tone="ok" ico="circle-check"> <St tone="ok" ico="circle-check">
已发布 生效中
</St> </St>
) : ( ) : (
<St tone="warn" ico="square-pen"> <St tone="mute" ico="history">
草稿 历史版本
</St> </St>
)} )}
</span> </span>
@@ -133,24 +139,24 @@ function PackagesHome({ tab, nav }: HomeProps) {
); );
} }
const published = packages.filter((p) => p.state === 'published').length; const flows = new Set(packages.map((p) => p.id)).size;
const totalEdges = packages.reduce((a, p) => a + p.edges.length, 0); const totalEdges = packages.reduce((a, p) => a + p.edges.length, 0);
const closedEdges = packages.reduce((a, p) => a + edgeCheckOf(p, reportOf(p)).closed, 0); const closedEdges = packages.reduce((a, p) => a + edgeCheckOf(p, reportOf(p)).closed, 0);
return ( return (
<QueryGate query={q}> <QueryGate query={q}>
<div className="g-kpis"> <div className="g-kpis">
<Kpi ico="circle-check" tone="ok" label="已发布流程包" value={published} sub="版本并存 · 在途按启动版本跑完" /> <Kpi ico="workflow" tone="ok" label="流程" value={flows} sub="每条流的最新一版生效中" />
<Kpi ico="square-pen" tone="warn" label="草稿" value={packages.length - published} sub="边不闭合不得发布" /> <Kpi ico="history" label="版本" value={packages.length} sub="恒新增 · 在途按启动版本跑完" />
<Kpi <Kpi
ico="git-merge" ico="git-merge"
label="边闭合率" label="边闭合率"
value={totalEdges === 0 ? 100 : Math.round((closedEdges / totalEdges) * 100)} value={totalEdges === 0 ? 100 : Math.round((closedEdges / totalEdges) * 100)}
unit="%" unit="%"
sub="连线即映射,画图即校验" sub="不闭合存不进来"
/> />
<Kpi ico="boxes" label="编排要素" value="五种" sub="活动 · 审批 · 网关 · 触发器 · 流程包" /> <Kpi ico="boxes" label="编排要素" value="五种" sub="活动 · 审批 · 网关 · 触发器 · 流程包" />
</div> </div>
<Note>恒新增、版本并存:发布不覆盖旧版本,此后新起的工作流用新版本,在途的按启动时版本跑完。</Note> <Note>严格检查 + 保存即生效:不闭合的编排存不进来;存下来即刻对**新起的**工作流生效,在途的按启动时版本跑完。保存恒新增版本,旧版本原样留着。</Note>
</QueryGate> </QueryGate>
); );
} }
@@ -172,6 +178,7 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
); );
} }
const siblings = (q.data?.packages ?? []).filter((x) => x.id === pkg.id); const siblings = (q.data?.packages ?? []).filter((x) => x.id === pkg.id);
const latestVersion = Math.max(...siblings.map((x) => x.version));
return ( return (
<Orchestrator <Orchestrator
key={`${pkg.id}@${pkg.version}`} key={`${pkg.id}@${pkg.version}`}
@@ -179,8 +186,9 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
nav={nav} nav={nav}
activities={acts.data?.activities ?? []} activities={acts.data?.activities ?? []}
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))} positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
nextVersion={Math.max(...siblings.map((x) => x.version)) + 1} nextVersion={latestVersion + 1}
draftSibling={siblings.filter((x) => x.state !== 'published').sort((a, b) => b.version - a.version)[0]} /* 生效中 = 这条流版本号最大的那一版:只有它能改(改则存成下一版);历史版本只读 */
live={pkg.version === latestVersion && pkg.state !== 'retired'}
/> />
); );
} }
@@ -201,17 +209,18 @@ function Orchestrator({
activities, activities,
positions, positions,
nextVersion, nextVersion,
draftSibling, live,
}: { }: {
pkg: ProcessPackage; pkg: ProcessPackage;
nav: Nav; nav: Nav;
activities: readonly ActivityRow[]; activities: readonly ActivityRow[];
positions: SpPosition[]; positions: SpPosition[];
nextVersion: number; nextVersion: number;
draftSibling: ProcessPackage | undefined; live: boolean;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const published = pkg.state === 'published'; /** 只读的是**历史版本**(不是「已发布」)—— 生效中的那一版可以改,改则存成下一版 */
const readOnly = !live;
const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]); const paradigms = useMemo(() => new Map(activities.map((a) => [a.activityId, a.paradigm])), [activities]);
/** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */ /** 画布工作副本:属性卡与手绘编辑都改它;保存时合并回流程包 */
@@ -239,7 +248,7 @@ function Orchestrator({
}); });
const activeReport = report ?? liveReport.data; const activeReport = report ?? liveReport.data;
const ec = edgeCheckOf(pkg, activeReport); const ec = edgeCheckOf(pkg, activeReport);
const okEdge = activeReport?.publishable === true; const okEdge = activeReport?.closed === true;
const patch = (f: (n: FlowNode) => FlowNode) => { const patch = (f: (n: FlowNode) => FlowNode) => {
if (sel === null) return; if (sel === null) return;
@@ -296,29 +305,6 @@ function Orchestrator({
}, },
}); });
const publish = useMutation({
mutationFn: () =>
call<ClosureReport>(publishProcessRoute, { params: { id: pkg.id, version: String(pkg.version) } }),
onSuccess: (r) => {
setReport(r);
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
},
});
/**
* 起草新版本 —— 已发布不可回改,改图的路是**克隆成下一版草稿**再编辑:
* 连手绘存证一起带走,落地即跳到新草稿页(那里「编辑」芯片与属性卡全开)。
*/
const draftNext = useMutation({
mutationFn: () =>
call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, {
body: { pkg: { ...pkg, version: nextVersion, state: 'draft' } },
}),
onSuccess: (out) => {
void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] });
nav.push({ k: 'pkg', id: `${out.pkg.id}@${out.pkg.version}` });
},
});
/** /**
* 导出依赖文件 —— 编排定契约:各活动的接口声明、责任岗位、输入/输出声明与上游装配, * 导出依赖文件 —— 编排定契约:各活动的接口声明、责任岗位、输入/输出声明与上游装配,
@@ -373,18 +359,18 @@ function Orchestrator({
<> <>
<PageHead <PageHead
ico="workflow" ico="workflow"
tone={published ? 'ok' : 'warn'} tone={live ? 'ok' : 'mute'}
kicker={`${pkg.id} · v${pkg.version}`} kicker={`${pkg.id} · v${pkg.version}`}
title={`${pkg.name} v${pkg.version}`} title={`${pkg.name} v${pkg.version}`}
chips={ chips={
<> <>
{published ? ( {live ? (
<St tone="ok" ico="circle-check"> <St tone="ok" ico="circle-check">
已发布 生效中
</St> </St>
) : ( ) : (
<St tone="warn" ico="square-pen"> <St tone="mute" ico="history">
草稿 历史版本
</St> </St>
)} )}
<St <St
@@ -405,38 +391,21 @@ function Orchestrator({
</> </>
} }
acts={[ acts={[
...(published /*
? [ * 历史版本只读 —— 改图要回到生效中的那一版(在那儿保存会存成下一版)。
draftSibling !== undefined * 从前这里是「已发布不可回改 → 起草新版本 / 去草稿编辑」两条路;
? { * 没有草稿之后只剩一条:生效版就地改,保存即新版。
label: `去草稿 v${draftSibling.version} 编辑`, */
...(live
? []
: [
{
label: `去 v${nextVersion - 1} 改(生效中)`,
ico: 'square-pen', ico: 'square-pen',
primary: true, primary: true,
onClick: () => nav.push({ k: 'pkg', id: `${draftSibling.id}@${draftSibling.version}` }), onClick: () => nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }),
}
: {
label: '起草新版本',
ico: 'square-pen',
primary: true,
ask: {
title: `起草新版本:${pkg.name} v${nextVersion}`,
note: (
<Note>
已发布版本不可回改 —— 改图的路是克隆成 v{nextVersion} 草稿再编排,
手绘几何与全部声明一并带走。
</Note>
),
impact: {
who: '仅新草稿;本版与在途工作流不受影响',
undo: '草稿不发布就不生效,可反复改',
when: '确认后立即建稿并跳转',
}, },
okLabel: '建稿并去编辑', ]),
onOk: () => draftNext.mutate(),
},
},
]
: []),
{ {
label: '导出依赖文件', label: '导出依赖文件',
ico: 'upload', ico: 'upload',
@@ -453,70 +422,40 @@ function Orchestrator({
onOk: exportDeps, onOk: exportDeps,
}, },
}, },
{
label: '发布',
ico: 'send',
primary: true,
disabled: published,
ask: {
title: `发布:${pkg.name} v${pkg.version}`,
note: okEdge ? (
<Note tone="ok">边映射全闭合、必填齐、类型无冲突 —— 可发布。</Note>
) : (
<Note tone="warn">{activeReport?.violations.map((v) => v.message).join(';') ?? '闭合校验未跑完'}</Note>
),
impact: {
who: okEdge ? '此后新起的工作流用新版本;在途按启动时版本跑完' : '被阻断 —— 先补齐边映射',
undo: okEdge ? '版本并存,可停用回退' : '—',
when: okEdge ? '确认后即时' : '不生效',
},
okLabel: okEdge ? '确认发布' : '我知道了',
onOk: () => {
if (okEdge) publish.mutate();
},
},
},
]} ]}
/> />
{activeReport !== undefined && !activeReport.publishable && ( {activeReport !== undefined && !activeReport.closed && (
<Note tone="warn"> <Note tone="warn">
<b>未闭合:</b> <b>未闭合:</b>
{activeReport.violations.map((v) => v.message).join(';')} {activeReport.violations.map((v) => v.message).join(';')}
</Note> </Note>
)} )}
{report !== undefined && report.publishable && <Note tone="ok">闭合校验通过。</Note>} {report !== undefined && report.closed && <Note tone="ok">闭合校验通过。</Note>}
{/* 编排工作区:左 2/3 画布(整图显示,不裁切)+ 右 1/3 属性卡(就地填写,不下钻) */} {/* 编排工作区:左 2/3 画布(整图显示,不裁切)+ 右 1/3 属性卡(就地填写,不下钻) */}
<Sec <Sec
ico="route" ico="route"
title="编排工作区" title="编排工作区"
extra={published ? '已发布版本只读 —— 改图要另起一版' : '点画布上的活动 → 右栏属性卡直接改;「编辑」进手绘模式'} extra={readOnly ? '历史版本只读 —— 改图回到生效中那一版' : '点画布上的活动 → 右栏属性卡直接改;「编辑」进手绘模式;保存即生效'}
> >
{/* {/*
已发布不可回改是规矩,但「去哪儿改」不该让人自己找 —— 历史版本只读,但「去哪儿改」不该让人自己找 —— 就地把路指出来。
就地把路指出来:一句为什么 + 一颗按钮,点了直接建稿并跳过去。 从前这里是「克隆成草稿再编排」;没有草稿之后,生效版就地改、保存即存成下一版。
*/} */}
{published && ( {readOnly && (
<div className="g-todo"> <div className="g-todo">
<Todo <Todo
tone="warn" tone="warn"
ico="square-pen" ico="history"
title={`v${pkg.version} 已发布,画布只读`} title={`v${pkg.version} 是历史版本,画布只读`}
desc={`改图的路是克隆成 v${nextVersion} 草稿再编排 —— 手绘几何与全部声明一并带走,本版与在途工作流不受影响。${ desc={`在途任务还按这一版跑完,所以它不可改。改图去 v${nextVersion - 1}(生效中那一版)——`
draftSibling === undefined ? '' : `(已有 v${draftSibling.version} 草稿在,直接去那儿改。)` + `在那儿保存会存成 v${nextVersion},本版与在途工作流一概不动。`}
}`}
acts={[ acts={[
draftSibling === undefined {
? { label: `去 v${nextVersion - 1} 改`,
label: draftNext.isPending ? '建稿中…' : `起草 v${nextVersion} 并去编辑`,
ico: 'square-pen', ico: 'square-pen',
onClick: () => draftNext.mutate(), onClick: () => nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }),
}
: {
label: `去草稿 v${draftSibling.version} 编辑`,
ico: 'square-pen',
onClick: () => nav.push({ k: 'pkg', id: `${draftSibling.id}@${draftSibling.version}` }),
}, },
]} ]}
/> />
@@ -533,7 +472,7 @@ function Orchestrator({
graph={graph} graph={graph}
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)} drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
positions={positions} positions={positions}
canEdit={!published} canEdit={!readOnly}
onNodeClick={(nid) => setSel(nid)} onNodeClick={(nid) => setSel(nid)}
onEditNodeText={async (nid, p2) => { onEditNodeText={async (nid, p2) => {
setGraph((g) => setGraph((g) =>
@@ -769,24 +708,31 @@ function Orchestrator({
align="end" align="end"
items={[ items={[
{ {
label: dirty ? '保存编排' : '编排无改动', label: dirty ? `保存并生效(存为 v${nextVersion})` : '编排无改动',
ico: 'send', ico: 'send',
primary: dirty, primary: dirty,
disabled: !dirty || published, disabled: !dirty || readOnly,
ask: dirty ask: dirty
? { ? {
title: `保存编排:${pkg.name} v${pkg.version}`, title: `保存并生效:${pkg.name} → v${nextVersion}`,
note: ( note: okEdge ? (
<Note> <Note tone="ok">
编排定契约:改活动声明即改边映射两端,保存后按新拓扑重算闭合。定义对在途任务不可变。 边映射全闭合、必填齐、类型无冲突 —— 存下去即刻对新起的工作流生效。
</Note>
) : (
<Note tone="warn">
<b>不闭合存不进来:</b>
{activeReport?.violations.map((v) => v.message).join(';') ?? '闭合校验未跑完'}
</Note> </Note>
), ),
impact: { impact: {
who: published ? '已发布版本不可回改 —— 另起新版本' : '本草稿版本的编排定义', who: okEdge
undo: '版本并存,可回退上一版本', ? `存为 v${nextVersion},此后**新起**的工作流用它;在途的按启动时版本跑完`
when: '保存即入草稿;发布后新起的工作流才用它', : '被拒存 —— 先补齐边映射',
undo: `v${pkg.version} 原样留着,可回到那一版再改`,
when: okEdge ? '确认即生效,没有发布这一步' : '存不进去',
}, },
okLabel: '确认保存', okLabel: okEdge ? '确认保存并生效' : '我知道了',
onOk: () => { onOk: () => {
if (graph !== null) save.mutate({ graph, idMap: {} }); if (graph !== null) save.mutate({ graph, idMap: {} });
}, },
@@ -15,7 +15,7 @@ const basePkg: ProcessPackage = processPackageSchema.parse({
id: 'p', id: 'p',
name: '包', name: '包',
version: 1, version: 1,
state: 'draft', state: 'active',
callInterface: { input: { type: 'object' }, output: { type: 'object' } }, callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [ nodes: [
{ id: 'S', kind: 'start', label: '起' }, { id: 'S', kind: 'start', label: '起' },
+12
View File
@@ -0,0 +1,12 @@
-- 流程包去掉发布机制:严格检查 + 保存即生效(产品决策 2026-07-28)
--
-- 状态枚举从 draft/published/retired 收成 active/retired。旧值不归一的话,
-- `processPackageSchema.parse` 读一行就抛 —— definition 这个 jsonb 里也存着一份 state,
-- 两处都要改。实测过:不改就是「一读就炸」,不是开发期小麻烦。
--
-- draft 也并入 active:旧草稿里可能有不闭合的,但新机制下不闭合根本存不进来;
-- 已经在库里的既存事实不追溯删除,交由治理者看到后自行处置(或 retire 掉)。
update sys.process_package
set state = 'active',
definition = jsonb_set(definition, '{state}', '"active"')
where state in ('draft', 'published');
+7
View File
@@ -85,6 +85,13 @@
"when": 1785129993168, "when": 1785129993168,
"tag": "0011_little_pet_avengers", "tag": "0011_little_pet_avengers",
"breakpoints": true "breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1785130053168,
"tag": "0012_process_no_publish",
"breakpoints": true
} }
] ]
} }
+8 -12
View File
@@ -192,12 +192,13 @@ export const listProcessPackagesRoute = {
output: z.object({ packages: z.array(processPackageSchema) }), output: z.object({ packages: z.array(processPackageSchema) }),
} as const satisfies RouteContract; } as const satisfies RouteContract;
/** 画图即校验 —— 与前端画布同源的闭合校验 */
/** /**
* 保存流程包(编排工作区的落笔)。 * 保存流程包 —— **严格检查 + 保存即生效**,没有发布这一道门。
* **保存 ≠ 发布**:落的是这一版的定义(草稿可反复存);已发布版本不可回改 —— *
* 后端按 (id, version, state) 判:published 版拒存,提示另起新版本。 * 不闭合即拒存(412 带闭合报告):存下来的就是能起单的。
* 返回连同闭合报告:画图即校验,每次落笔都知道离「可发布」还差几条。 * 保存**恒产生新版本号**,由服务端算 —— body 里的 `version` 只用于「改的是哪一版」的溯源,
* 服务端不采信它当目标版本号。在途工作流按 (包id, 版本) 读定义,就地覆盖会改掉它们读的东西。
* 返回的 `pkg.version` 是**实际落的新版本**,`report` 是这一版的闭合报告(必然 closed)。
*/ */
export const saveProcessRoute = { export const saveProcessRoute = {
method: 'PUT', method: 'PUT',
@@ -206,6 +207,8 @@ export const saveProcessRoute = {
output: z.object({ pkg: processPackageSchema, report: closureReportSchema }), output: z.object({ pkg: processPackageSchema, report: closureReportSchema }),
} as const satisfies RouteContract; } as const satisfies RouteContract;
/** 画图即校验 —— 与前端画布同源;保存前随手看还差几条,不改任何状态 */
export const validateProcessRoute = { export const validateProcessRoute = {
method: 'POST', method: 'POST',
path: '/api/gov/process/validate', path: '/api/gov/process/validate',
@@ -213,13 +216,6 @@ export const validateProcessRoute = {
output: closureReportSchema, output: closureReportSchema,
} as const satisfies RouteContract; } as const satisfies RouteContract;
export const publishProcessRoute = {
method: 'POST',
path: '/api/gov/process/:id/:version/publish',
params: z.object({ id: z.string(), version: z.string() }),
output: closureReportSchema,
} as const satisfies RouteContract;
/* ── 触发器配置 ──────────────────────────────────────────────────────── */ /* ── 触发器配置 ──────────────────────────────────────────────────────── */
export const listTriggersRoute = { export const listTriggersRoute = {
+14 -2
View File
@@ -198,7 +198,18 @@ export type CallInterface = z.infer<typeof callInterfaceSchema>;
/* ── 流程包 ───────────────────────────────────────────────────────────── */ /* ── 流程包 ───────────────────────────────────────────────────────────── */
export const PROCESS_PACKAGE_STATES = ['draft', 'published', 'retired'] as const; /**
* 流程包状态 —— **没有草稿态,也没有发布态**。
*
* 「严格检查 + 保存生效」:保存时跑闭合校验,不闭合直接拒存;存下来的就是生效的。
* 生效与否由**运行时**决定 —— 新工作流起单时取最新版,在途的按启动时快照的版本跑完。
* 所以不需要「已发布」这个标记:能存进来的版本都能起单。
*
* 保存**恒产生新版本号**(服务端算):工作流按 (包id, 版本) 读定义,
* 就地覆盖会改掉在途任务正在读的东西 —— 那正是「旧任务走完旧版本」要防的。
* `retired` 留给「这一版不再可起单」,起单时拒。
*/
export const PROCESS_PACKAGE_STATES = ['active', 'retired'] as const;
export const processPackageStateSchema = z.enum(PROCESS_PACKAGE_STATES); export const processPackageStateSchema = z.enum(PROCESS_PACKAGE_STATES);
export type ProcessPackageState = z.infer<typeof processPackageStateSchema>; export type ProcessPackageState = z.infer<typeof processPackageStateSchema>;
@@ -248,7 +259,8 @@ export const closureViolationSchema = z.object({
export type ClosureViolation = z.infer<typeof closureViolationSchema>; export type ClosureViolation = z.infer<typeof closureViolationSchema>;
export const closureReportSchema = z.object({ export const closureReportSchema = z.object({
publishable: z.boolean(), /** 边是否闭合 —— 不闭合即**拒存**(从前叫 publishable,那时闭合只拦发布) */
closed: z.boolean(),
violations: z.array(closureViolationSchema), violations: z.array(closureViolationSchema),
}); });
export type ClosureReport = z.infer<typeof closureReportSchema>; export type ClosureReport = z.infer<typeof closureReportSchema>;