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

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

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

`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
@@ -16,7 +16,6 @@ import {
useGovActivities,
useGovObjects,
useGovPackages,
useGovPkgReports,
useGovTools,
useGovTriggers,
} from '@web-shared/gov/gov-api.ts';
@@ -31,11 +30,12 @@ export function BlueprintHome({ nav }: HomeProps) {
const trgs = useGovTriggers();
const packages = pkgs.data?.packages ?? [];
const reports = useGovPkgReports(packages);
const reportOf = (id: string, version: number) => reports.data?.get(`${id}@${version}`);
const drafts = packages.filter((p) => p.state !== 'published');
const draftBad = drafts.filter((p) => reportOf(p.id, p.version)?.publishable === false);
/*
* 没有「草稿」也没有「未闭合」可数了 —— 严格检查 + 保存即生效:不闭合的包**存不进来**。
* 硬摆这两个计数就是摆两个恒为 0 的假指标。改数真能说话的:每条流有几版、最新是第几版。
*/
const flowCount = new Set(packages.map((p) => p.id)).size;
const activities = acts.data?.activities ?? [];
const toolRows = tools.data?.tools ?? [];
const objectRows = objs.data?.objects ?? [];
@@ -57,7 +57,7 @@ export function BlueprintHome({ nav }: HomeProps) {
(id) => !triggers.some((t) => t.processPackageId === id),
);
const gaps = draftBad.length + missingActivities.length + uncalled.length;
const gaps = missingActivities.length + uncalled.length;
// 因果链是这一屏的主角:四库以真箭头相连 —— 箭头编码的是真实依赖
// (流程包引用活动、活动调工具、活动物化数据),节点卡即入口,点卡即入库。
@@ -71,9 +71,9 @@ export function BlueprintHome({ nav }: HomeProps) {
</span>
战略 · 流程包
</span>
<span className="g-chain-v">{packages.length}</span>
<span className="g-chain-v">{flowCount}</span>
<span className="g-chain-h">
企业以哪些流转运转 —— 草稿 {drafts.length} · 未闭合 {draftBad.length}
企业以哪些流转运转 —— 共 {packages.length} 个版本,保存即生效
</span>
</button>
<span className="g-chain-arr">
@@ -124,32 +124,9 @@ export function BlueprintHome({ nav }: HomeProps) {
<Sec ico="flag" title="待补清单" extra="定义上的缺口聚在这里,点进即改 —— 不散落在四库">
{gaps === 0 ? (
<Clear>没有缺口 —— 流程包全部闭合,引用的活动都装好了,每条流也都有触发器调得起来。</Clear>
<Clear>没有缺口 —— 引用的活动都装好了,每条流也都有触发器调得起来。</Clear>
) : (
<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) => (
<Todo
key={`miss-${id}`}
@@ -17,8 +17,6 @@ import {
useGovActivities,
useGovMemory,
useGovMetrics,
useGovPackages,
useGovPkgReports,
useGovTasks,
} from '@web-shared/gov/gov-api.ts';
@@ -44,17 +42,12 @@ const TERMINAL_TONE: Readonly<Record<string, string>> = {
export function CockpitHome({ nav }: HomeProps) {
const metrics = useGovMetrics();
const tasks = useGovTasks();
const packages = useGovPackages();
const reports = useGovPkgReports(packages.data?.packages ?? []);
const m = metrics.data;
const workflows = tasks.data?.workflows ?? [];
const taskRows = tasks.data?.tasks ?? [];
const abnormal = workflows.filter((w) => w.state === 'abnormal').length;
const running = taskRows.filter((t) => t.terminal === undefined).length;
const draftBad = (packages.data?.packages ?? []).filter(
(p) => p.state !== 'published' && reports.data?.get(`${p.id}@${p.version}`)?.publishable === false,
).length;
const mix = m?.terminalMix ?? {};
const mixTop = Object.entries(mix)
@@ -72,7 +65,12 @@ export function CockpitHome({ nav }: HomeProps) {
value={abnormal}
sub="失败走向未消化,待人工处置"
/>
<Kpi ico="git-merge" tone="warn" label="未闭合草稿" value={draftBad} sub="边不闭合不得发布" />
{/*
* 「未闭合草稿」这张卡撤了 —— 严格检查 + 保存即生效之后,不闭合的包**存不进来**,
* 这个数结构上恒为 0。摆一个永远是 0 的卡位就是在占地方装样子。
* 天然的第三张是「死路」(网关一条边都判不中而卡住的流,已有 route-dead-end 事件),
* 但那需要一条按事件类型聚合的查询路由 —— 没有之前不摆(同死信卡的规矩)。
*/}
<Kpi ico="activity" tone="brand" label="在途任务" value={running} sub="尚未落终态的任务" />
</div>
{abnormal > 0 ? (
@@ -5,13 +5,13 @@
* `WorkflowsHome` / `PkgPage` / `EdgesPage` 平移:DOM 结构、类名(`g-*`)与文案原样保留,
* mock 常量换成服务端投影(README 平移规则 1/2/4)。
*
* 编排即定契约:连线即映射、画图即校验、**边不闭合不得发布**;
* 编排即定契约:连线即映射、画图即校验、**边不闭合不得保存**;
* 图画完可导出活动开发的依赖文件 —— 开发者对着声明在系统外开发。
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import type { ClosureReport, ProcessPackage, ProcessNode } from '@contracts';
import { publishProcessRoute, saveProcessRoute, validateProcessRoute, TRIGGER_KIND_LABEL } from '@contracts';
import { saveProcessRoute, validateProcessRoute, TRIGGER_KIND_LABEL } from '@contracts';
import { call } from '@web-shared/typed-fetch.ts';
import { Ico } from '@web-shared/Ico.tsx';
import {
@@ -79,15 +79,21 @@ function PackagesHome({ tab, nav }: HomeProps) {
return (
<QueryGate query={q}>
<div className="g-shelf">
{/*
* 没有「已发布 / 草稿」了 —— 严格检查 + 保存即生效。
* 真正的区别是「哪一版会被新起单取到」:同一条流里**版本号最大的那一版生效中**,
* 更早的版本是历史(在途任务还照它跑完)。
*/}
{packages.map((p) => {
const published = p.state === 'published';
const latestOfFlow = Math.max(...packages.filter((x) => x.id === p.id).map((x) => x.version));
const live = p.version === latestOfFlow && p.state !== 'retired';
const ec = edgeCheckOf(p, reportOf(p));
const okEdge = ec.closed === ec.total;
const c = counts(p);
return (
<button
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}` })}
>
<span className="g-tile-h">
@@ -98,13 +104,13 @@ function PackagesHome({ tab, nav }: HomeProps) {
<b>{p.name}</b>
<em>v{p.version}</em>
</span>
{published ? (
{live ? (
<St tone="ok" ico="circle-check">
已发布
生效中
</St>
) : (
<St tone="warn" ico="square-pen">
草稿
<St tone="mute" ico="history">
历史版本
</St>
)}
</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 closedEdges = packages.reduce((a, p) => a + edgeCheckOf(p, reportOf(p)).closed, 0);
return (
<QueryGate query={q}>
<div className="g-kpis">
<Kpi ico="circle-check" tone="ok" label="已发布流程包" value={published} sub="版本并存 · 在途按启动版本跑完" />
<Kpi ico="square-pen" tone="warn" label="草稿" value={packages.length - published} sub="边不闭合不得发布" />
<Kpi ico="workflow" tone="ok" label="流程" value={flows} sub="每条流的最新一版生效中" />
<Kpi ico="history" label="版本" value={packages.length} sub="恒新增 · 在途按启动版本跑完" />
<Kpi
ico="git-merge"
label="边闭合率"
value={totalEdges === 0 ? 100 : Math.round((closedEdges / totalEdges) * 100)}
unit="%"
sub="连线即映射,画图即校验"
sub="不闭合存不进来"
/>
<Kpi ico="boxes" label="编排要素" value="五种" sub="活动 · 审批 · 网关 · 触发器 · 流程包" />
</div>
<Note>恒新增、版本并存:发布不覆盖旧版本,此后新起的工作流用新版本,在途的按启动时版本跑完。</Note>
<Note>严格检查 + 保存即生效:不闭合的编排存不进来;存下来即刻对**新起的**工作流生效,在途的按启动时版本跑完。保存恒新增版本,旧版本原样留着。</Note>
</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 latestVersion = Math.max(...siblings.map((x) => x.version));
return (
<Orchestrator
key={`${pkg.id}@${pkg.version}`}
@@ -179,8 +186,9 @@ export function PkgPage({ id, nav }: { id: string; nav: Nav }) {
nav={nav}
activities={acts.data?.activities ?? []}
positions={(staffQ.data?.positions ?? []).map((p) => ({ id: p.id, name: p.name, abbr: p.code }))}
nextVersion={Math.max(...siblings.map((x) => x.version)) + 1}
draftSibling={siblings.filter((x) => x.state !== 'published').sort((a, b) => b.version - a.version)[0]}
nextVersion={latestVersion + 1}
/* 生效中 = 这条流版本号最大的那一版:只有它能改(改则存成下一版);历史版本只读 */
live={pkg.version === latestVersion && pkg.state !== 'retired'}
/>
);
}
@@ -201,17 +209,18 @@ function Orchestrator({
activities,
positions,
nextVersion,
draftSibling,
live,
}: {
pkg: ProcessPackage;
nav: Nav;
activities: readonly ActivityRow[];
positions: SpPosition[];
nextVersion: number;
draftSibling: ProcessPackage | undefined;
live: boolean;
}) {
const queryClient = useQueryClient();
const published = pkg.state === 'published';
/** 只读的是**历史版本**(不是「已发布」)—— 生效中的那一版可以改,改则存成下一版 */
const readOnly = !live;
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 ec = edgeCheckOf(pkg, activeReport);
const okEdge = activeReport?.publishable === true;
const okEdge = activeReport?.closed === true;
const patch = (f: (n: FlowNode) => FlowNode) => {
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
ico="workflow"
tone={published ? 'ok' : 'warn'}
tone={live ? 'ok' : 'mute'}
kicker={`${pkg.id} · v${pkg.version}`}
title={`${pkg.name} v${pkg.version}`}
chips={
<>
{published ? (
{live ? (
<St tone="ok" ico="circle-check">
已发布
生效中
</St>
) : (
<St tone="warn" ico="square-pen">
草稿
<St tone="mute" ico="history">
历史版本
</St>
)}
<St
@@ -405,38 +391,21 @@ function Orchestrator({
</>
}
acts={[
...(published
? [
draftSibling !== undefined
? {
label: `去草稿 v${draftSibling.version} 编辑`,
ico: 'square-pen',
primary: true,
onClick: () => nav.push({ k: 'pkg', id: `${draftSibling.id}@${draftSibling.version}` }),
}
: {
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(),
},
},
]
: []),
/*
* 历史版本只读 —— 改图要回到生效中的那一版(在那儿保存会存成下一版)。
* 从前这里是「已发布不可回改 → 起草新版本 / 去草稿编辑」两条路;
* 没有草稿之后只剩一条:生效版就地改,保存即新版。
*/
...(live
? []
: [
{
label: `去 v${nextVersion - 1} 改(生效中)`,
ico: 'square-pen',
primary: true,
onClick: () => nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }),
},
]),
{
label: '导出依赖文件',
ico: 'upload',
@@ -453,71 +422,41 @@ function Orchestrator({
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">
<b>未闭合:</b>
{activeReport.violations.map((v) => v.message).join(';')}
</Note>
)}
{report !== undefined && report.publishable && <Note tone="ok">闭合校验通过。</Note>}
{report !== undefined && report.closed && <Note tone="ok">闭合校验通过。</Note>}
{/* 编排工作区:左 2/3 画布(整图显示,不裁切)+ 右 1/3 属性卡(就地填写,不下钻) */}
<Sec
ico="route"
title="编排工作区"
extra={published ? '已发布版本只读 —— 改图要另起一版' : '点画布上的活动 → 右栏属性卡直接改;「编辑」进手绘模式'}
extra={readOnly ? '历史版本只读 —— 改图回到生效中那一版' : '点画布上的活动 → 右栏属性卡直接改;「编辑」进手绘模式;保存即生效'}
>
{/*
已发布不可回改是规矩,但「去哪儿改」不该让人自己找 ——
就地把路指出来:一句为什么 + 一颗按钮,点了直接建稿并跳过去。
历史版本只读,但「去哪儿改」不该让人自己找 —— 就地把路指出来。
从前这里是「克隆成草稿再编排」;没有草稿之后,生效版就地改、保存即存成下一版。
*/}
{published && (
{readOnly && (
<div className="g-todo">
<Todo
tone="warn"
ico="square-pen"
title={`v${pkg.version} 已发布,画布只读`}
desc={`改图的路是克隆成 v${nextVersion} 草稿再编排 —— 手绘几何与全部声明一并带走,本版与在途工作流不受影响。${
draftSibling === undefined ? '' : `(已有 v${draftSibling.version} 草稿在,直接去那儿改。)`
}`}
ico="history"
title={`v${pkg.version} 是历史版本,画布只读`}
desc={`在途任务还按这一版跑完,所以它不可改。改图去 v${nextVersion - 1}(生效中那一版)——`
+ `在那儿保存会存成 v${nextVersion},本版与在途工作流一概不动。`}
acts={[
draftSibling === undefined
? {
label: draftNext.isPending ? '建稿中…' : `起草 v${nextVersion} 并去编辑`,
ico: 'square-pen',
onClick: () => draftNext.mutate(),
}
: {
label: `去草稿 v${draftSibling.version} 编辑`,
ico: 'square-pen',
onClick: () => nav.push({ k: 'pkg', id: `${draftSibling.id}@${draftSibling.version}` }),
},
{
label: `去 v${nextVersion - 1} 改`,
ico: 'square-pen',
onClick: () => nav.push({ k: 'pkg', id: `${pkg.id}@${nextVersion - 1}` }),
},
]}
/>
</div>
@@ -533,7 +472,7 @@ function Orchestrator({
graph={graph}
drilledIds={nodes.filter((n) => n.kind === 'activity').map((n) => n.id)}
positions={positions}
canEdit={!published}
canEdit={!readOnly}
onNodeClick={(nid) => setSel(nid)}
onEditNodeText={async (nid, p2) => {
setGraph((g) =>
@@ -769,24 +708,31 @@ function Orchestrator({
align="end"
items={[
{
label: dirty ? '保存编排' : '编排无改动',
label: dirty ? `保存并生效(存为 v${nextVersion})` : '编排无改动',
ico: 'send',
primary: dirty,
disabled: !dirty || published,
disabled: !dirty || readOnly,
ask: dirty
? {
title: `保存编排:${pkg.name} v${pkg.version}`,
note: (
<Note>
编排定契约:改活动声明即改边映射两端,保存后按新拓扑重算闭合。定义对在途任务不可变。
title: `保存并生效:${pkg.name} → v${nextVersion}`,
note: okEdge ? (
<Note tone="ok">
边映射全闭合、必填齐、类型无冲突 —— 存下去即刻对新起的工作流生效。
</Note>
) : (
<Note tone="warn">
<b>不闭合存不进来:</b>
{activeReport?.violations.map((v) => v.message).join(';') ?? '闭合校验未跑完'}
</Note>
),
impact: {
who: published ? '已发布版本不可回改 —— 另起新版本' : '本草稿版本的编排定义',
undo: '版本并存,可回退上一版本',
when: '保存即入草稿;发布后新起的工作流才用它',
who: okEdge
? `存为 v${nextVersion},此后**新起**的工作流用它;在途的按启动时版本跑完`
: '被拒存 —— 先补齐边映射',
undo: `v${pkg.version} 原样留着,可回到那一版再改`,
when: okEdge ? '确认即生效,没有发布这一步' : '存不进去',
},
okLabel: '确认保存',
okLabel: okEdge ? '确认保存并生效' : '我知道了',
onOk: () => {
if (graph !== null) save.mutate({ graph, idMap: {} });
},