三面拆分⑯:流程包详情改三栏 · 字段只声明 · 版本可删可改名

**字段只声明,不做任何要求**(用户定):编排层不再碰字段 ——
  · 激活闸只剩两面:结构闭合(dangling-node / multi-default-edge)与活动装载
    (unknown-activity);字段类的检查(同名不同型、容忍分支被必填依赖)全部退役。
  · 节点属性卡的「数据接口」从「推来源」改成**纯声明**:照活动契约列出吃什么、吐什么,
    不标必填、不推来源、不提补全 —— 缺了怎么补是**活动内实现**的事,不在这一层现形。
  · 字段类型支持数组:统一写成 `T[]`(如 `any[]`),出口口的字段也带上类型
    (原来只有字段名)。实测 A2 的 benchmark 口显示「rows any[] · needSourcing boolean」。

**详情页改三栏**:左窄轨版本(132px) | 中流程图 | 右节点属性。
  · 版本条一版一行(v几 · 状态 · 点/边计数),动作只在**看着的那一版**上展开 ——
    窄栏放不下每行一排钮,也不该让人误点别版。
  · 删掉「激活 · 双判据」整卡:没选节点时,属性栏就地列出还差哪几条(点一条定位过去);
    两面都齐时盖一枚小检验章,不再占一整张卡。
  · 删掉节点拣选行 —— 直接在图上点节点。
  · 属性卡用包壳绝对定位:行高由中栏的图定,属性长了在卡内滚,不把图拉长
    (实测卡高 467、明细内容 1039、可滚,图框不受影响)。窄轨里属性小卡改单列竖排。
  · 顺带校正:上一轮那条「图框下沿对齐左列底栏顶线」的 20px 留白,
    三栏改版后左栏已无底栏,没有对齐对象了 —— 改回四边等距(18px)。

**版本可删可改名**(新):
  · 删:生效版不可删(先停用,ActiveVersionError → 409)、在途钉版不可删
    (那些单要按它跑完,PinnedVersionError → 409);删掉正看的那版会落到剩下的最新版,
    一版不剩就回货架。
  · 改名:名字是**这条流的身份**,一次改及于所有版本 —— 版本列表上只显示 v1/v2…,
    若只改一版的名,列表上什么也看不见,人会以为没生效。
  · 5 条单测守两道闸与改名口径;实测走查:改名落库 → 改回;生效版删 409、停用版删 200。

process CONTRACT.md 与产品设计.md 同步。五闸绿。

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-29 16:14:54 +08:00
co-authored by Claude Opus 5
parent f03f23f2ca
commit c9ac058bdf
22 changed files with 775 additions and 396 deletions
@@ -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<string, unknown>), 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));
@@ -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<ProcessNode, { kind: 'activity' }>,
inbound: readonly Edge[],
contracts: ReadonlyMap<string, ActivityContractFace>,
byId: ReadonlyMap<string, ProcessNode>,
): 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<string, ActivityContractFace>,
byId: ReadonlyMap<string, ProcessNode>,
): 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 {
@@ -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();
},