Archived
基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
|
||||
- 用例工厂:`createProcessService(deps): ProcessService`
|
||||
- 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState`
|
||||
(`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011)
|
||||
`isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady`
|
||||
`rejectRouteOf` `dispatchOf` `startNodeOf`
|
||||
- adapter:`createProcessStore`;Drizzle 表:`processPackageTable` `workflowTable` `nodeInstanceTable`
|
||||
|
||||
@@ -69,7 +69,7 @@ function harness(validationErrors: readonly string[]): Harness {
|
||||
findInstanceByTask: async () => undefined,
|
||||
},
|
||||
activities: { faces: async () => new Map() },
|
||||
org: { positions: async () => new Set<string>(), candidates: async () => [] },
|
||||
org: { positions: async () => new Set<string>(), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true },
|
||||
tasks: { start: async () => ({ id: 'task-1' }) },
|
||||
approvals: { raise: async () => ({ id: 'apr-1' }) },
|
||||
sfeel: { condition: () => true, expression: () => undefined },
|
||||
|
||||
@@ -30,6 +30,8 @@ export const workflowTable = sys.table(
|
||||
/** 凡单必有来源 */
|
||||
source: jsonb('source').notNull(),
|
||||
initiation: jsonb('initiation').notNull(),
|
||||
/** 工作组绑定快照(ADR-011)—— 起单时装载,之后改组不影响这条单 */
|
||||
workGroup: jsonb('work_group'),
|
||||
ports: jsonb('ports').notNull().default({}),
|
||||
vars: jsonb('vars').notNull().default({}),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -20,6 +20,7 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord {
|
||||
state: row.state as WorkflowState,
|
||||
source: row.source as WorkflowRecord['source'],
|
||||
initiation: row.initiation as Record<string, unknown>,
|
||||
...(row.workGroup === null ? {} : { workGroup: row.workGroup as Record<string, string> }),
|
||||
ports: row.ports as Record<string, Record<string, unknown>>,
|
||||
vars: row.vars as Record<string, unknown>,
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
@@ -80,6 +81,7 @@ export function createProcessStore(db: Db): ProcessStore {
|
||||
state: record.state,
|
||||
source: record.source,
|
||||
initiation: record.initiation,
|
||||
workGroup: record.workGroup ?? null,
|
||||
ports: record.ports,
|
||||
vars: record.vars,
|
||||
startedAt: new Date(record.startedAt),
|
||||
|
||||
@@ -16,20 +16,47 @@ export type ResolveContext = {
|
||||
readonly candidates: readonly Candidate[];
|
||||
/** 来源跟随:同一工作流内该岗位已解析出的人 */
|
||||
readonly alreadyResolved?: string | undefined;
|
||||
/**
|
||||
* 工作组绑定(ADR-011):该岗位绑定到的人,**调用方已确认其在岗**。
|
||||
*
|
||||
* 为什么不在这里核:`candidates` 是**该岗位**的在岗人员,
|
||||
* 而工作组的意义恰恰是跨岗位指派 —— 拿岗位候选集去核绑定,绑定永远核不过。
|
||||
* 在岗与否是组织事实,归调用方向 org 问;域侧只认已校验的结果。
|
||||
*/
|
||||
readonly bound?: string | undefined;
|
||||
};
|
||||
|
||||
export type Resolution =
|
||||
| { readonly kind: 'resolved'; readonly staffId: string; readonly by: OwnerSpec['strategy']; readonly rationale: string }
|
||||
| {
|
||||
readonly kind: 'resolved';
|
||||
readonly staffId: string;
|
||||
/** 'work-group' 是**绑定**,不是第五种策略 —— 留痕里一眼可辨 */
|
||||
readonly by: OwnerSpec['strategy'] | 'work-group';
|
||||
readonly rationale: string;
|
||||
}
|
||||
/** 岗位空缺 —— 按节点的失败走向处置,或转治理台待指派 */
|
||||
| { readonly kind: 'vacant'; readonly position: string };
|
||||
|
||||
/**
|
||||
* 「编排声明岗位,起单时**确定性解析**。策略封闭四选;
|
||||
* 默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,**结果与依据留痕**。」
|
||||
*
|
||||
* **绑定先于策略**(ADR-011):四策略回答的是「这个岗位该派给谁」,
|
||||
* 工作组已经给出了答案,策略于是无从发问。绑定的人若已离岗则不硬用,退回策略 ——
|
||||
* 绑定不该把单派给一个不在岗的人。
|
||||
*/
|
||||
export function resolveOwner(spec: OwnerSpec, context: ResolveContext): Resolution {
|
||||
const active = context.candidates;
|
||||
|
||||
if (context.bound !== undefined) {
|
||||
return {
|
||||
kind: 'resolved',
|
||||
staffId: context.bound,
|
||||
by: 'work-group',
|
||||
rationale: `工作组绑定:${spec.position}`,
|
||||
};
|
||||
}
|
||||
|
||||
switch (spec.strategy) {
|
||||
case 'assigned': {
|
||||
const staffId = spec.staffId;
|
||||
|
||||
@@ -46,6 +46,20 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起单取版:未指定版本时取**最新已发布** —— 草稿不入选。
|
||||
* 若按「最新版本」取,治理台一存草稿,起单立刻被没发布的定义顶住;
|
||||
* 草稿是编排中的半成品,运行时永远不该看见它。
|
||||
*/
|
||||
async function latestPublished(id: string): Promise<ProcessPackage> {
|
||||
const all = await deps.store.listPackages();
|
||||
const hit = all
|
||||
.filter((pkg) => pkg.id === id && pkg.state === 'published')
|
||||
.sort((a, b) => b.version - a.version)[0];
|
||||
if (hit === undefined) throw new Error(`流程包无已发布版本,不得起单:${id}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
async function requireWorkflow(id: string): Promise<WorkflowRecord> {
|
||||
const record = await deps.store.getWorkflow(id);
|
||||
if (record === undefined) throw new Error(`工作流不存在:${id}`);
|
||||
@@ -104,7 +118,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
// 负责人 / 审批人:编排声明岗位,起单时确定性解析;结果与依据留痕
|
||||
const spec = dispatch.kind === 'start-task' ? dispatch.node.owner : dispatch.node.approver;
|
||||
const candidates = await deps.org.candidates(spec.position);
|
||||
const resolution = resolveOwner(spec, { candidates });
|
||||
/*
|
||||
* 绑定先于策略:工作组说了这个岗位是谁,策略就无从发问(ADR-011)。
|
||||
* 在岗与否在这里核 —— 绑定是**跨岗位**指派,拿该岗位的候选集核不了它。
|
||||
*/
|
||||
const boundTo = record.workGroup?.[spec.position];
|
||||
const bound = boundTo !== undefined && (await deps.org.isActive(boundTo)) ? boundTo : undefined;
|
||||
const resolution = resolveOwner(spec, { candidates, ...(bound === undefined ? {} : { bound }) });
|
||||
if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position);
|
||||
|
||||
await deps.events.publish({
|
||||
@@ -246,7 +266,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
|
||||
/** 起单:经调用接口调起流程包,生成一条工作流 */
|
||||
async start(input: StartWorkflowInput) {
|
||||
const pkg = await requirePackage(input.packageId, input.packageVersion);
|
||||
const pkg =
|
||||
input.packageVersion === undefined
|
||||
? await latestPublished(input.packageId)
|
||||
: await requirePackage(input.packageId, input.packageVersion);
|
||||
if (pkg.state !== 'published') throw new Error(`流程包未发布,不得起单:${pkg.id}@${pkg.version}`);
|
||||
|
||||
// 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。
|
||||
@@ -254,6 +277,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
const check = deps.schemas.validate(pkg.callInterface.input, input.data);
|
||||
if (!check.ok) throw new CallInterfaceViolationError(pkg.id, check.errors);
|
||||
|
||||
// 工作组按**来源人**装载(ADR-011):无来源人、或此人没建组 → 不装载,全走四策略
|
||||
const binding =
|
||||
input.source.by === undefined ? undefined : await deps.org.workGroupBinding(input.source.by);
|
||||
|
||||
const record: WorkflowRecord = {
|
||||
id: deps.ids.next(),
|
||||
packageId: pkg.id,
|
||||
@@ -265,6 +292,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
ports: {},
|
||||
vars: {},
|
||||
startedAt: deps.clock.now().toISOString(),
|
||||
...(binding === undefined ? {} : { workGroup: { ...binding } }),
|
||||
};
|
||||
await deps.store.insertWorkflow(record);
|
||||
await deps.events.publish({ workflowId: record.id, kind: 'workflow-started', detail: { source: input.source } });
|
||||
|
||||
@@ -18,6 +18,11 @@ export type WorkflowRecord = {
|
||||
/** 来源:凡单必有来源(触发器 / 登记人 / 上游系统) */
|
||||
readonly source: { kind: 'periodic' | 'event' | 'registration'; ref: string; by?: string };
|
||||
readonly initiation: Record<string, unknown>;
|
||||
/**
|
||||
* 工作组绑定的**快照**(ADR-011):起单时按来源人装载,之后改组不影响这条单。
|
||||
* 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。
|
||||
*/
|
||||
readonly workGroup?: Record<string, string>;
|
||||
/** 各节点输出口载荷 —— 边映射装配的来源 */
|
||||
readonly ports: Record<string, Record<string, unknown>>;
|
||||
readonly vars: Record<string, unknown>;
|
||||
@@ -56,6 +61,10 @@ export type ActivityDirectory = {
|
||||
export type OrgDirectory = {
|
||||
positions(): Promise<Set<string>>;
|
||||
candidates(position: string): Promise<Array<{ staffId: string; name: string; openTasks: number }>>;
|
||||
/** 某人的工作组绑定(ADR-011)—— 起单时按来源人取,取不到即不装载 */
|
||||
workGroupBinding(staffId: string): Promise<Readonly<Record<string, string>> | undefined>;
|
||||
/** 在岗与否 —— 绑定的人已离岗就不硬用,退回四策略 */
|
||||
isActive(staffId: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
/** 起单与终态 —— app 注入 `task`(L6) */
|
||||
|
||||
Reference in New Issue
Block a user