三面拆分⑰:编辑即其身 —— 保存写回本版,新版本只出自「新建」

用户问:为什么编辑一个版本、保存就自动多一个副本?那不是我要的。
旧机制「保存恒新增版本号」改为:

- savePackage:pkg.version 已存在 → **就地写回该版**(号不变、状态不变);
  `asNew: true`(新建按钮)或新流程首版才铸新版本号(服务端算,不生效)
- 底线三道,一道不少:
  ① **在途钉版不可变** —— 该版有未完结工作流(按 (包,版本) 钉定义)→ 409,
    一字不落库;「就地覆盖会改掉在途正在读的编排」由这道闸守,不再靠只插不改一刀切
  ② **生效版必须始终可运行** —— 写回生效版跑闭合闸,不闭合 412 带整份报告;
    未生效版画图不检查、随手存
  ③ **陈旧编辑不复活** —— 流程存在而版本号已被清理 → 409(实测:停在已删旧版上的
    标签页发来保存,把老内容静默复活成了新版本;StaleVersionError 拦死)
- store:新增 updatePackageDefinition(只许命中现存版本)+ countPinnedInFlight
- 前端:保存按钮「保存 v{n}」;新建走 asNew;文案全线「新建才长版本」
- 测试:save-package.test 六例(写回/钉版/热改闭合/asNew/防复活/首版);
  过程中揪出双 API 进程并存(旧进程霸端口跑旧代码)——已重启归一

实测:改名保存 → 版本列表仍唯一 v4 生效中,名字已写回;八例端到端全绿;
五闸退出码全过(api:check 提交后转绿)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 20:11:41 +08:00
co-authored by Claude Fable 5
parent 2246b26438
commit a10a7b2855
16 changed files with 312 additions and 36 deletions
@@ -0,0 +1,139 @@
/**
* 保存 —— **编辑即其身**(产品决策 2026-07-28 第四轮):
* 写回当前版本号不变;新版本只出自显式新建(asNew)或新流程首版。
* 两道底线:在途钉版不可变;生效版写回必须闭合(生效的必须始终可运行)。
*/
import { describe, expect, it } from 'vitest';
import { processPackageSchema } from '@contracts';
import type { ProcessPackage } from '@contracts';
import { NotClosedError, PinnedVersionError, StaleVersionError } from '../contract.ts';
import { createProcessService } from '../internal/usecase/process-service.ts';
import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts';
const basePkg = (version: number, state: 'active' | 'retired'): ProcessPackage =>
processPackageSchema.parse({
id: 'p1',
name: '包',
version,
state,
callInterface: { input: { type: 'object' }, output: { type: 'object' } },
nodes: [
{ id: 'S', kind: 'start', label: '起' },
{ id: 'E', kind: 'end', label: '终' },
],
edges: [{ id: 'E1', from: 'S', to: 'E', mapping: [], reopen: false }],
variables: [],
});
function harness(seed: ProcessPackage[], pinned = 0) {
const shelf = [...seed];
const updated: Array<{ id: string; version: number }> = [];
const inserted: ProcessPackage[] = [];
const deps: ProcessServiceDeps = {
store: {
savePackage: async (pkg) => {
inserted.push(pkg);
shelf.push(pkg);
},
updatePackageDefinition: async (id, version, pkg) => {
const at = shelf.findIndex((p) => p.id === id && p.version === version);
if (at < 0) throw new Error('版本不存在');
shelf[at] = pkg;
updated.push({ id, version });
},
countPinnedInFlight: async () => pinned,
setPackageState: async () => undefined,
getPackage: async (id, version) =>
version === undefined
? [...shelf].sort((a, b) => b.version - a.version).find((p) => p.id === id)
: shelf.find((p) => p.id === id && p.version === version),
listPackages: async () => shelf,
insertWorkflow: async () => undefined,
getWorkflow: async () => undefined,
listWorkflows: async () => [],
updateWorkflow: async () => {
throw new Error('测试:不该走到 updateWorkflow');
},
listInstances: async () => [],
insertInstance: async () => undefined,
updateInstance: async () => undefined,
findInstanceByTask: async () => undefined,
},
activities: { faces: async () => new Map() },
org: {
positions: async () => new Set<string>(['PM']),
candidates: async () => [],
workGroupBinding: async () => undefined,
isActive: async () => true,
},
tasks: { start: async () => ({ id: 't' }) },
approvals: { raise: async () => ({ id: 'a' }) },
sfeel: { condition: () => true, expression: () => undefined },
schemas: { validate: () => ({ ok: true, errors: [] }) },
events: { publish: async () => undefined },
receipts: { notify: async () => undefined },
clock: { now: () => new Date('2026-07-28T00:00:00.000Z') },
ids: { next: () => 'x' },
};
return { service: createProcessService(deps), shelf, updated, inserted };
}
describe('savePackage —— 编辑即其身', () => {
it('已存在的版本就地写回:号不变、状态不变、不长新版', async () => {
const h = harness([basePkg(4, 'retired')]);
const edited = { ...basePkg(4, 'retired'), name: '改名了' };
const saved = await h.service.savePackage(edited);
expect(saved.version).toBe(4);
expect(saved.state).toBe('retired');
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
expect(h.inserted).toHaveLength(0);
expect(h.shelf).toHaveLength(1);
});
it('在途钉版不可变 —— 有未完结工作流即拒,一字不落库', async () => {
const h = harness([basePkg(4, 'retired')], 2);
await expect(h.service.savePackage(basePkg(4, 'retired'))).rejects.toBeInstanceOf(PinnedVersionError);
expect(h.updated).toHaveLength(0);
expect(h.inserted).toHaveLength(0);
});
it('写回生效版须闭合 —— 生效的必须始终可运行', async () => {
const h = harness([basePkg(4, 'active')]);
// 把唯一一条边砍掉:起点无出边、终点无入边 —— 不闭合
const broken = { ...basePkg(4, 'active'), edges: [] };
await expect(h.service.savePackage(broken)).rejects.toBeInstanceOf(NotClosedError);
expect(h.updated).toHaveLength(0);
// 闭合的改动照常写回,生效状态保持
const ok = { ...basePkg(4, 'active'), name: '热改' };
const saved = await h.service.savePackage(ok);
expect(saved.state).toBe('active');
expect(h.updated).toEqual([{ id: 'p1', version: 4 }]);
});
it('asNew 显式新建:铸下一个版本号,不生效 —— 新版本只出自这条路', async () => {
const h = harness([basePkg(4, 'active')]);
const saved = await h.service.savePackage(basePkg(4, 'active'), { asNew: true });
expect(saved.version).toBe(5);
expect(saved.state).toBe('retired');
expect(h.inserted).toHaveLength(1);
expect(h.updated).toHaveLength(0);
});
it('陈旧编辑拒之门外:流程存在而版本号已不存在 —— 不静默复活旧内容', async () => {
const h = harness([basePkg(4, 'active')]);
// 停在已清理旧版(v1)上的标签页发来保存
await expect(h.service.savePackage(basePkg(1, 'retired'))).rejects.toBeInstanceOf(StaleVersionError);
expect(h.inserted).toHaveLength(0);
expect(h.updated).toHaveLength(0);
});
it('新流程首版:版本不存在即插入 v1,不生效', async () => {
const h = harness([]);
const saved = await h.service.savePackage(basePkg(1, 'active'));
expect(saved.version).toBe(1);
expect(saved.state).toBe('retired');
expect(h.inserted).toHaveLength(1);
});
});