forked from ChaiHao/BPOS-MVP
三面拆分⑯:流程包详情改三栏 · 字段只声明 · 版本可删可改名
**字段只声明,不做任何要求**(用户定):编排层不再碰字段 ——
· 激活闸只剩两面:结构闭合(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:
@@ -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<AppEnv>, 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<AppEnv>, 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<string, { type?: string }>;
|
||||
properties?: Record<string, FieldSpec>;
|
||||
};
|
||||
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<string, unknown> } | undefined;
|
||||
return Object.keys(p?.properties ?? {});
|
||||
const p = payload as { properties?: Record<string, FieldSpec> } | 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 就能衔接下游
|
||||
|
||||
@@ -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,替代「连线即映射」):边不带映射,
|
||||
只表达顺序与条件(条件读上游输出字段)。节点入参 = 铺平作用域
|
||||
(起单入参 ← 沿途各输出口字段 ← 刚发射的载荷,后者赢)按下游契约取;
|
||||
|
||||
@@ -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 —— 四策略确定性解析', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<typeof createProcessService>;
|
||||
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(/不存在/);
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,11 @@ export type ProcessService = {
|
||||
listWorkflows(limit?: number): Promise<WorkflowRecord[]>;
|
||||
instancesOf(workflowId: string): Promise<NodeInstance[]>;
|
||||
|
||||
/** 删一版:生效版与在途钉版都不许删(见 `ActiveVersionError` / `PinnedVersionError`) */
|
||||
deleteVersion(id: string, version: number): Promise<ProcessPackage[]>;
|
||||
/** 改名:名字是这条流的身份,一次改及于所有版本 */
|
||||
rename(id: string, name: string): Promise<ProcessPackage[]>;
|
||||
|
||||
/* ── 节点负责人(运营配置):显式指派 > 这里 > 轮值 ── */
|
||||
nodeOwners(packageId: string): Promise<Record<string, string>>;
|
||||
setNodeOwner(packageId: string, nodeId: string, staffId: string | undefined): Promise<void>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -76,6 +76,9 @@ export type ProcessStore = {
|
||||
countPinnedInFlight(id: string, version: number): Promise<number>;
|
||||
/** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */
|
||||
setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise<void>;
|
||||
deletePackage(id: string, version: number): Promise<void>;
|
||||
/** 改名及于这条流的所有版本 —— 名字是流的身份,不是版本的属性 */
|
||||
renameFlow(id: string, name: string): Promise<void>;
|
||||
getPackage(id: string, version?: number): Promise<ProcessPackage | undefined>;
|
||||
listPackages(): Promise<ProcessPackage[]>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user