基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
+1
View File
@@ -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 } });
+9
View File
@@ -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) */