Archived
三面拆分⑭:宽进收敛 —— 起单不卡缺项,进活动受理归一,信息不全派补全单
用户定的逻辑:「调用活动,输入数据可以不定或者为空;进入活动后受理归一,
信息不全派补全单。」把收敛点从门口挪进活动 —— 三道门口闸全撤:
· 触发装配:缺必填**不再入死信**(AssemblyResult 的 missing 改 partial,带上已装配的);
装到多少带多少照样起单。死信只留给真错误(勾了确认没指定人这类配置错误)。
· process.start:撤掉调用接口缺项闸 —— 从前缺一个字段整条流都起不来,
现实是客户来信本来就常缺客户码,拦在门外等于要求外界替系统把信息备齐。
· task.start(替代旧 AM 12.1「缺项不得启动」):缺必填的任务落成「等人」,
缺的字段做成**补全卡**(elicitation,标签取 schema 的 title)发到负责人的
任务对话流,并武装 human 监听(IntakeDesk 端口;不占活动的人卡预算)。
答复只收契约声明过的键,合并进 data;齐了或答「没法填」才转 running 开跑;
答一半就就缺的部分再派一张卡。等人期间谱一步不走、不扣步数。
· 测试台预检改成**信息**不是闸:灯灭不拦起单,文案直说
「抽不出的键由受理补全单找人收齐」。
5 条受理归一单测 + 全链实测:投一封只写「想问下工控板价格」的信(零键)→
流照起 → A1 落负责人工位、等人、补全卡在对话流 → 答卡(customerCode/rfqRef)→
任务醒、合并、谱跑 3 步到验收。修一处接线错误:监听 deadline 要 ISO-8601
时长(P2D)不是时刻,传错整个补全监听静默没装上。
trigger/task/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:
@@ -70,10 +70,13 @@ describe('一封信 → 调用入参', () => {
|
||||
expect(out.data['rfqRef']).toBe('RFQ-2607-002');
|
||||
});
|
||||
|
||||
it('抽不出来就如实报缺哪个 —— 不静默起一张空单', () => {
|
||||
it('抽不全:如实列出缺哪个,但**带着已有的照样能起** —— 宽进,收敛在活动', () => {
|
||||
const out = assemble('随便问问,有没有便宜的板子');
|
||||
expect(out.kind).toBe('missing');
|
||||
if (out.kind !== 'missing') return;
|
||||
expect(out.kind).toBe('partial');
|
||||
if (out.kind !== 'partial') return;
|
||||
expect(out.fields).toEqual(expect.arrayContaining(['customerCode', 'rfqRef']));
|
||||
// 缺的键不以 undefined 占位 —— 下游按「键在不在」判缺不缺
|
||||
expect('customerCode' in out.data).toBe(false);
|
||||
expect(out.data['subject'], '抽得到的照带').toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,15 +152,7 @@ async function assembleFromMail(
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
if (assembled.kind === 'missing') {
|
||||
const wanted = channel.extract
|
||||
.filter((r) => assembled.fields.includes(r.key))
|
||||
.map((r) => `${r.key} 要匹配 /${r.regex}/`);
|
||||
throw new Error(
|
||||
`这封信里抽不出 ${assembled.fields.join('、')} —— 通道「${channel.name}」的抽键规则没命中` +
|
||||
(wanted.length > 0 ? `(${wanted.join(';')})` : ''),
|
||||
);
|
||||
}
|
||||
// 宽进:抽不全也照起 —— 缺的键由活动受理归一、补全单收齐(与线上事件起单同一条路)
|
||||
return assembled.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { TriggerService } from '@modules/trigger';
|
||||
|
||||
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import type { StaffId, TaskId } from '@contracts';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import { cardIdSchema, staffIdSchema } from '@contracts';
|
||||
import { createAgentRunner } from '../agent-runner.ts';
|
||||
import { createMastraAgentRunner } from '../mastra-agent-runner.ts';
|
||||
import type { Clock, IdGenerator } from '../runtime.ts';
|
||||
@@ -772,6 +772,32 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
},
|
||||
},
|
||||
interaction: { setPhase: async (taskId, phase) => void (await deps.interaction.setPhase(taskId, phase)) },
|
||||
/*
|
||||
* 受理补全台(宽进):缺必填 → 补全卡进任务对话流 + 武装 human 监听等答复。
|
||||
* 不占活动的人卡预算 —— 补全发生在活动开跑之前,是受理的事,不是活动的打扰。
|
||||
*/
|
||||
intake: {
|
||||
askCompletion: async (input) => {
|
||||
const card = {
|
||||
id: cardIdSchema.parse(ids.next()),
|
||||
at: clock.now().toISOString(),
|
||||
k: 'elicitation' as const,
|
||||
ask: `受理这一单还缺几个信息 —— 补上,流程才跑得下去;实在没法填就写明原因。`,
|
||||
fields: input.fields.map((f) => ({ key: f.key, label: f.label })),
|
||||
};
|
||||
await deps.humanTool.issueChatCard(
|
||||
{ taskId: input.taskId as never, ownerId: input.ownerId as never, humanCallCap: 1_000_000 },
|
||||
card,
|
||||
);
|
||||
await listeners.register({
|
||||
taskId: input.taskId as never,
|
||||
eventType: 'human',
|
||||
keyFilter: { cardId: card.id },
|
||||
// 时限是 ISO-8601 **时长**(P2D = 两天),不是时刻 —— 到点抛时间事件,无死等
|
||||
deadline: 'P2D',
|
||||
});
|
||||
},
|
||||
},
|
||||
clock,
|
||||
ids,
|
||||
});
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
被依赖: trigger, app(治理台 · 工作流管理)
|
||||
|
||||
不变量(OS 03 / 3.2):
|
||||
- **宽进**(2026-07-30):`start` 不再按调用接口拒缺项 —— 输入可以不定或为空,
|
||||
收敛点在活动(任务侧受理归一 + 补全单)。`CallInterfaceViolationError` 保留导出,
|
||||
仅存量引用,起单路径不再抛它
|
||||
- **节点负责人三层**(2026-07-30):起单时合并 —— 显式指派(start 入参,测试台定向)>
|
||||
运营配置(`sys.node_owner`,运营中心 · 工作流里配)> 轮值(员工组 → 全员最闲者先)。
|
||||
运营配置不随包版本 —— 它说的是「这家企业现在这活谁管」,不是流程长什么样。
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { processPackageSchema } from '@contracts';
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import { CallInterfaceViolationError } from '../contract.ts';
|
||||
import { createProcessService } from '../internal/usecase/process-service.ts';
|
||||
import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts';
|
||||
import type { WorkflowRecord } from '../port.ts';
|
||||
@@ -93,7 +92,7 @@ function harness(validationErrors: readonly string[]): Harness {
|
||||
return { service: createProcessService(deps), inserted, published };
|
||||
}
|
||||
|
||||
describe('起单:凡单必合契约', () => {
|
||||
describe('起单:宽进 —— 输入可以不定或为空', () => {
|
||||
const input = {
|
||||
packageId: 'p1',
|
||||
subject: '测试单',
|
||||
@@ -101,17 +100,12 @@ describe('起单:凡单必合契约', () => {
|
||||
data: {},
|
||||
};
|
||||
|
||||
it('入参不合调用接口 → 抛错,且**不建工作流、不发事件**', async () => {
|
||||
const { service, inserted, published } = harness(["must have required property 'customerCode'"]);
|
||||
await expect(service.start(input)).rejects.toBeInstanceOf(CallInterfaceViolationError);
|
||||
// 校验若放在建单之后,这里就会留下一条永远推不动的在途单,污染在途口径
|
||||
expect(inserted).toHaveLength(0);
|
||||
expect(published).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('错误带上缺了哪个字段 —— 登记人据此补齐,而不是猜', async () => {
|
||||
const { service } = harness(["must have required property 'customerCode'"]);
|
||||
await expect(service.start(input)).rejects.toThrow(/customerCode/);
|
||||
it('宽进(2026-07-30):入参缺项**照样建单** —— 收敛点在活动,不在门口', async () => {
|
||||
const { service, inserted } = harness(["must have required property 'customerCode'"]);
|
||||
const record = await service.start(input);
|
||||
expect(record.id).toBeDefined();
|
||||
expect(inserted).toHaveLength(1);
|
||||
// 缺的字段就是缺着 —— 进活动后受理归一,信息不全由任务侧派补全单收齐
|
||||
});
|
||||
|
||||
it('入参合契约 → 正常建单,来源记在单上', async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { ProcessPackage } from '@contracts';
|
||||
import type { ProcessService, StartWorkflowInput } from '../../contract.ts';
|
||||
import { CallInterfaceViolationError, NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts';
|
||||
import { NotClosedError, PinnedVersionError, StaleVersionError } from '../../contract.ts';
|
||||
import type {
|
||||
ActivityDirectory,
|
||||
ApprovalGateway,
|
||||
@@ -180,10 +180,12 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
// 指名道姓要某一版也得是生效版 —— 「一次只能激活一个版本」对新起单是硬约束
|
||||
if (pkg.state !== 'active') throw new Error(`该版本未生效,不得起单:${pkg.id}@${pkg.version}`);
|
||||
|
||||
// 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。
|
||||
// (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。)
|
||||
const check = deps.schemas.validate(pkg.callInterface.input, input.data);
|
||||
if (!check.ok) throw new CallInterfaceViolationError(pkg.id, check.errors);
|
||||
/*
|
||||
* 宽进(2026-07-30,用户定):调用接口的输入**可以不定或为空** —— 门口不设缺项闸。
|
||||
* 收敛点在活动:进活动后受理归一,信息不全由任务侧派补全单收齐。
|
||||
* (从前这里「先校验后建单」,缺一个字段整条流都起不来 —— 现实是客户来信
|
||||
* 本来就常缺客户码,拦在门外等于要求外界替系统把信息备齐。)
|
||||
*/
|
||||
|
||||
// 工作组按**来源人**装载(ADR-011):无来源人、或此人没建组 → 不装载,全走四策略
|
||||
const binding =
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
被依赖: process, org(停用前置检查的在途任务), report, app
|
||||
|
||||
不变量(OS 04 / 4.3,AM 10 / 12.1):
|
||||
- **受理归一**(2026-07-30 宽进,替代旧 AM 12.1「缺项不得启动」):输入可以不定或为空 ——
|
||||
缺必填不拒单,任务落成 `awaiting-human`,缺的字段做成补全卡(`IntakeDesk` 端口)发给
|
||||
负责人并武装 human 监听;答复只收契约声明过的键,合并进 data,齐了(或答「没法填」)
|
||||
才转 running 开跑。等人期间谱一步不走、不扣步数;补全不占活动的人卡预算
|
||||
- **恰一负责人**:任务必绑定恰一个 `ownerId`;改派**留痕并通知双方**
|
||||
- **输入必过 schema**:`data` 缺项**不得启动**;`message` 计入初始观察但不得扩大授权
|
||||
- **预算环外强制**:每一拍先 `judge` 再交引擎 —— 活动代码无从自查自守;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 受理归一(2026-07-30 宽进,用户定的逻辑)——
|
||||
* 「调用活动,输入数据可以不定或者为空;进入活动后受理归一,信息不全派补全单。」
|
||||
*
|
||||
* 这里守四条:
|
||||
* ① 缺必填**不拒单**:任务落成「等人」,补全卡发给负责人,谱一步不走;
|
||||
* ② 补全答复只收契约声明过的键,合并进 data,齐了转 running;
|
||||
* ③ 答一半:合并已给的、就缺的部分再派一张卡,还是等人;
|
||||
* ④ 「没法填」不硬拦:人写明原因就带缺口开跑 —— 收敛是活动的事,不是门卫的事。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TaskId } from '@contracts';
|
||||
import type { LandedFacts, Task } from '../contract.ts';
|
||||
import { createTaskService } from '../internal/usecase/task-service.ts';
|
||||
import type { TaskServiceDeps } from '../internal/usecase/task-service.ts';
|
||||
|
||||
type Harness = {
|
||||
service: ReturnType<typeof createTaskService>;
|
||||
asked: Array<{ fields: Array<{ key: string; label: string }> }>;
|
||||
tasks: Map<string, Task>;
|
||||
};
|
||||
|
||||
const INPUT_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
customerCode: { type: 'string', title: '客户码' },
|
||||
rfqRef: { type: 'string', title: '询价单号' },
|
||||
subject: { type: 'string' },
|
||||
},
|
||||
required: ['customerCode', 'rfqRef'],
|
||||
};
|
||||
|
||||
function harness(): Harness {
|
||||
const asked: Harness['asked'] = [];
|
||||
const tasks = new Map<string, Task>();
|
||||
let seq = 0;
|
||||
|
||||
const deps: TaskServiceDeps = {
|
||||
store: {
|
||||
get: async (id) => tasks.get(id),
|
||||
insert: async (t) => {
|
||||
tasks.set(t.id, t);
|
||||
},
|
||||
update: async (id, patch) => {
|
||||
const next = { ...tasks.get(id)!, ...patch };
|
||||
tasks.set(id, next);
|
||||
return next;
|
||||
},
|
||||
addConsumption: async (id) => tasks.get(id)!.used,
|
||||
lastProgressAt: async () => new Date('2026-07-30T00:00:01.000Z'),
|
||||
markProgress: async () => undefined,
|
||||
listByOwner: async () => [],
|
||||
},
|
||||
activities: {
|
||||
load: async () => ({
|
||||
paradigm: 'flow' as const,
|
||||
caps: { steps: 40, tokens: 200000, humanCalls: 2, activeMinutes: 60 },
|
||||
envelope: {},
|
||||
inputSchema: INPUT_SCHEMA,
|
||||
businessModel: {},
|
||||
stallAfter: 'PT30M',
|
||||
}),
|
||||
},
|
||||
schemas: { validate: () => ({ ok: true, errors: [] }) },
|
||||
engines: {
|
||||
flow: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
|
||||
loop: { load: async () => undefined, advance: async () => ({ status: 'running' }), onEvent: async () => ({ status: 'running' }) },
|
||||
},
|
||||
memory: { append: async () => undefined, hasHistory: async () => true, seal: async () => undefined, distill: async () => undefined },
|
||||
landing: {
|
||||
land: async () => ({ converged: true, emittedPorts: [] as string[], ledgerRowsMerged: 0, facts: {} as LandedFacts }),
|
||||
},
|
||||
listeners: { hasArmedListener: async () => true },
|
||||
persistence: { hasDrafts: async () => false, hasRuntimeState: async () => true },
|
||||
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
|
||||
notifier: { notify: async () => undefined },
|
||||
interaction: { setPhase: async () => undefined },
|
||||
intake: {
|
||||
askCompletion: async (input) => {
|
||||
asked.push({ fields: input.fields.map((f) => ({ key: f.key, label: f.label })) });
|
||||
},
|
||||
},
|
||||
clock: { now: () => new Date('2026-07-30T00:00:00.000Z') },
|
||||
ids: { next: () => `t-${(seq += 1)}` },
|
||||
};
|
||||
|
||||
return { service: createTaskService(deps), asked, tasks };
|
||||
}
|
||||
|
||||
const START = {
|
||||
activityId: 'a1',
|
||||
ownerId: 's-1' as never,
|
||||
title: '客户RFQ受理',
|
||||
} as const;
|
||||
|
||||
describe('受理归一 —— 宽进,信息不全派补全单', () => {
|
||||
it('① 缺必填不拒单:任务落成等人,补全卡带中文标签发出去', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: { subject: '来信' } });
|
||||
|
||||
expect(task.runtime, '不开跑,先等人').toBe('awaiting-human');
|
||||
expect(h.asked).toHaveLength(1);
|
||||
expect(h.asked[0]!.fields).toEqual([
|
||||
{ key: 'customerCode', label: '客户码' },
|
||||
{ key: 'rfqRef', label: '询价单号' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('② 补全到齐:合并进 data、转 running;不认契约之外的键', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: {} });
|
||||
|
||||
await h.service.deliver(task.id as TaskId, {
|
||||
kind: 'human',
|
||||
ref: 'card-1',
|
||||
payload: { values: { customerCode: 'C014', rfqRef: 'RFQ-1', hacker: '塞私货' } },
|
||||
});
|
||||
const after = h.tasks.get(task.id)!;
|
||||
expect(after.runtime).toBe('running');
|
||||
expect(after.data['customerCode']).toBe('C014');
|
||||
expect(after.data['rfqRef']).toBe('RFQ-1');
|
||||
expect('hacker' in after.data, '契约没声明的键不收').toBe(false);
|
||||
});
|
||||
|
||||
it('③ 答一半:合并已给的,就缺的再派一张卡,还是等人', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: {} });
|
||||
|
||||
await h.service.deliver(task.id as TaskId, {
|
||||
kind: 'human',
|
||||
ref: 'card-1',
|
||||
payload: { values: { customerCode: 'C014' } },
|
||||
});
|
||||
const after = h.tasks.get(task.id)!;
|
||||
expect(after.runtime).toBe('awaiting-human');
|
||||
expect(after.data['customerCode']).toBe('C014');
|
||||
expect(h.asked, '第二张卡只要还缺的').toHaveLength(2);
|
||||
expect(h.asked[1]!.fields.map((f) => f.key)).toEqual(['rfqRef']);
|
||||
});
|
||||
|
||||
it('④ 没法填:写明原因就带缺口开跑 —— 收敛是活动的事', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: {} });
|
||||
|
||||
await h.service.deliver(task.id as TaskId, {
|
||||
kind: 'human',
|
||||
ref: 'card-1',
|
||||
payload: { values: {}, reason: '客户没给单号,先按来信受理' },
|
||||
});
|
||||
expect(h.tasks.get(task.id)!.runtime).toBe('running');
|
||||
});
|
||||
|
||||
it('输入齐全照旧直接开跑 —— 宽进不该让正常路多等一站', async () => {
|
||||
const h = harness();
|
||||
const task = await h.service.start({ ...START, data: { customerCode: 'C014', rfqRef: 'RFQ-1' } });
|
||||
expect(task.runtime).toBe('running');
|
||||
expect(h.asked).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,7 @@ function harness(land: () => Promise<never> | Promise<{ converged: boolean; emit
|
||||
events: { publishTerminal: async () => undefined, publishControl: async () => undefined },
|
||||
notifier: { notify: async () => undefined },
|
||||
interaction: { setPhase: async () => undefined },
|
||||
intake: { askCompletion: async () => undefined },
|
||||
clock: { now: () => new Date('2026-07-29T00:00:02.000Z') },
|
||||
ids: { next: () => 'x' },
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
ActivityLoader,
|
||||
Clock,
|
||||
IdGenerator,
|
||||
IntakeDesk,
|
||||
InteractionPhaseSetter,
|
||||
LandingGateway,
|
||||
ListenerProbe,
|
||||
@@ -39,10 +40,31 @@ export type TaskServiceDeps = {
|
||||
readonly events: TaskEventPublisher;
|
||||
readonly notifier: ReassignNotifier;
|
||||
readonly interaction: InteractionPhaseSetter;
|
||||
/**
|
||||
* 受理补全台(2026-07-30 宽进):缺必填时把补全卡发到负责人的任务对话流,
|
||||
* 并武装 human 监听等答复 —— 任务先「等人」,补齐了才开跑。
|
||||
*/
|
||||
readonly intake: IntakeDesk;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 缺了哪些必填 —— 键从 JSON-Schema 的 required 来,标签取 properties 里的 title / label,
|
||||
* 没有就用键名。补全卡要给人看的是「客户码」,不是 customerCode。
|
||||
*/
|
||||
function missingRequired(
|
||||
inputSchema: Record<string, unknown>,
|
||||
data: Record<string, unknown>,
|
||||
): Array<{ key: string; label: string }> {
|
||||
const required = Array.isArray(inputSchema['required']) ? (inputSchema['required'] as string[]) : [];
|
||||
const props = (inputSchema['properties'] ?? {}) as Record<string, { title?: string; label?: string }>;
|
||||
return required
|
||||
.filter((key) => data[key] === undefined || data[key] === null || data[key] === '')
|
||||
.map((key) => ({ key, label: props[key]?.title ?? props[key]?.label ?? key }));
|
||||
}
|
||||
|
||||
export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
async function require(taskId: TaskId): Promise<Task> {
|
||||
const task = await deps.store.get(taskId);
|
||||
@@ -69,9 +91,13 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
return {
|
||||
async start(input: StartTaskInput): Promise<Task> {
|
||||
const loaded = await deps.activities.load(input.activityId);
|
||||
// 输入 data 必过 schema 校验,**缺项不得启动**(AM 12.1)
|
||||
const check = deps.schemas.validate(loaded.inputSchema, input.data);
|
||||
if (!check.ok) throw new Error(`输入不合契约,不得启动:${check.errors.join('; ')}`);
|
||||
/*
|
||||
* 受理归一(2026-07-30 宽进,替代旧 AM 12.1 的「缺项不得启动」):
|
||||
* 输入**可以不定或为空** —— 缺必填不再拒单,而是任务先落成「等人」,
|
||||
* 把缺的字段做成补全卡发给负责人;补齐(或答「没法填」)后才开跑。
|
||||
* 收敛点从门口挪进活动:门口拒单,人连差什么都不知道;补全卡上写得明明白白。
|
||||
*/
|
||||
const missing = missingRequired(loaded.inputSchema, input.data);
|
||||
|
||||
const now = deps.clock.now();
|
||||
const task: Task = {
|
||||
@@ -82,7 +108,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
|
||||
ownerId: input.ownerId,
|
||||
title: input.title,
|
||||
runtime: 'running',
|
||||
runtime: missing.length > 0 ? 'awaiting-human' : 'running',
|
||||
data: input.data,
|
||||
envelope: loaded.envelope,
|
||||
caps: loaded.caps,
|
||||
@@ -99,6 +125,15 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
});
|
||||
await engineOf(task).load(task.id, loaded.businessModel);
|
||||
await deps.interaction.setPhase(task.id, 'active');
|
||||
if (missing.length > 0) {
|
||||
await deps.memory.append({
|
||||
taskId: task.id,
|
||||
kind: 'observation',
|
||||
title: `受理归一:缺 ${missing.map((m) => m.key).join('、')} —— 已派补全单`,
|
||||
detail: { missing: missing.map((m) => m.key) },
|
||||
});
|
||||
await deps.intake.askCompletion({ taskId: task.id, ownerId: task.ownerId, title: task.title, fields: missing });
|
||||
}
|
||||
await deps.store.markProgress(task.id, now);
|
||||
return task;
|
||||
},
|
||||
@@ -222,6 +257,52 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
title: `事件到达:${event.kind}/${event.ref}`,
|
||||
detail: event.payload,
|
||||
});
|
||||
|
||||
/*
|
||||
* 受理补全的答复(宽进):任务还在「等人」且谱一步没走 —— 这时候来的 human 事件
|
||||
* 只可能是补全卡(谱没开跑,别的卡还发不出来)。答的值**只收契约声明过的键**,
|
||||
* 合并进 data;齐了(或人答了「没法填」)就转 running,交给推进器开跑。
|
||||
* 「没法填」不硬拦:人已把话说明,带着缺口进活动,收敛是活动的事。
|
||||
*/
|
||||
if (task.runtime === 'awaiting-human' && event.kind === 'human') {
|
||||
const loaded = await deps.activities.load(task.activityId);
|
||||
const stillMissing = missingRequired(loaded.inputSchema, task.data);
|
||||
if (stillMissing.length > 0) {
|
||||
const values = (event.payload['values'] ?? {}) as Record<string, unknown>;
|
||||
const declared = new Set(stillMissing.map((m) => m.key));
|
||||
const accepted = Object.fromEntries(
|
||||
Object.entries(values).filter(([k, v]) => declared.has(k) && typeof v === 'string' && v.trim() !== ''),
|
||||
);
|
||||
const data = { ...task.data, ...accepted };
|
||||
const remain = missingRequired(loaded.inputSchema, data);
|
||||
const gaveUp = typeof event.payload['reason'] === 'string' && event.payload['reason'] !== '';
|
||||
if (remain.length === 0 || gaveUp) {
|
||||
await deps.store.update(taskId, { data, runtime: 'running' });
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: remain.length === 0 ? '受理归一:补全到齐,开跑' : `受理归一:${remain.map((m) => m.key).join('、')} 没法填 —— 带缺口开跑`,
|
||||
detail: { merged: Object.keys(accepted), remain: remain.map((m) => m.key) },
|
||||
});
|
||||
} else {
|
||||
await deps.store.update(taskId, { data });
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'observation',
|
||||
title: `受理归一:收到 ${Object.keys(accepted).join('、') || '(空)'},还缺 ${remain.map((m) => m.key).join('、')}`,
|
||||
detail: { merged: Object.keys(accepted), remain: remain.map((m) => m.key) },
|
||||
});
|
||||
await deps.intake.askCompletion({ taskId, ownerId: task.ownerId, title: task.title, fields: remain });
|
||||
// 还在等人 —— 不 tick:引擎一步都不该走,谱要等补全齐了才开
|
||||
await deps.store.markProgress(taskId, deps.clock.now());
|
||||
const held = await require(taskId);
|
||||
return { task: held, verdict: { kind: 'ok' }, progressed: false };
|
||||
}
|
||||
await deps.store.markProgress(taskId, deps.clock.now());
|
||||
return this.tick(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
await engineOf(task).onEvent(taskId, event);
|
||||
await deps.store.markProgress(taskId, deps.clock.now());
|
||||
return this.tick(taskId);
|
||||
|
||||
@@ -112,6 +112,19 @@ export type ReassignNotifier = {
|
||||
};
|
||||
|
||||
/** 交互态 —— app 注入 `interaction`(L3):控制权按态转交 */
|
||||
/**
|
||||
* 受理补全台(2026-07-30 宽进):把缺的必填字段做成补全卡发到负责人的任务对话流,
|
||||
* 并武装 human 监听等答复 —— app 注入 human-tool + event。
|
||||
*/
|
||||
export type IntakeDesk = {
|
||||
askCompletion(input: {
|
||||
taskId: string;
|
||||
ownerId: string;
|
||||
title: string;
|
||||
fields: ReadonlyArray<{ key: string; label: string }>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
export type InteractionPhaseSetter = {
|
||||
setPhase(taskId: TaskId, phase: 'activating' | 'active' | 'awaiting-human' | 'awaiting-event' | 'transitioning' | 'terminal'): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
- **只负责起单**:经调用接口调起流程包;流程包与活动内部不含任何触发逻辑
|
||||
- **同一事件流,不另设通路**:`match` 供 event 的三级消费判定使用
|
||||
- **同一事件对同一触发器至多起一单**:以事件 ID 为幂等键
|
||||
- **缺必填即起单失败入死信**,不静默丢弃
|
||||
- **宽进**(2026-07-30,用户定,替代旧「缺必填即入死信」):缺必填**照样起单** ——
|
||||
装到多少带多少(`AssemblyResult.partial`);收敛点在活动:受理归一、信息不全派补全单。
|
||||
死信只留给真错误(勾了确认没指定人、包无生效版这类配置错误)
|
||||
- **数据日历型同一行同一日期只触发一次**(键含日期值);字段值变更后可再触发
|
||||
- 命中规则求值失败按**不命中**处置 —— 规则写坏了不让事件流停摆
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('assembleInput', () => {
|
||||
it('事件触发:scope 不打底 —— 事件载荷与调用接口是两套字段名', () => {
|
||||
// 不声明 mapping 就装配不出必填字段,应入死信而不是把事件载荷整个塞给流程包
|
||||
const r = assembleInput(event(), { subject: '询价', customerCode: 'C001' }, ['customerCode'], noExpr);
|
||||
expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] });
|
||||
expect(r).toMatchObject({ kind: 'partial', fields: ['customerCode'] });
|
||||
});
|
||||
|
||||
it('事件触发:mapping 声明了才装配得出来', () => {
|
||||
@@ -61,8 +61,8 @@ describe('assembleInput', () => {
|
||||
expect(r).toEqual({ kind: 'ready', data: { customerCode: 'C001' } });
|
||||
});
|
||||
|
||||
it('缺必填即 missing,列出缺哪几个', () => {
|
||||
it('缺必填即 partial,列出缺哪几个 —— 但已装上的照带', () => {
|
||||
const r = assembleInput(registration(), { rfqRef: 'RFQ-1' }, ['customerCode', 'rfqRef'], noExpr);
|
||||
expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] });
|
||||
expect(r).toMatchObject({ kind: 'partial', fields: ['customerCode'] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('matchesEvent —— 流程层的系统级事件监听', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleInput —— 缺必填即起单失败入死信', () => {
|
||||
describe('assembleInput —— 缺必填即 partial —— 宽进,收敛在活动', () => {
|
||||
const evalExpr = (expr: string, s: Readonly<Record<string, unknown>>): unknown => evalSfeel(expr, s);
|
||||
|
||||
it('必填齐备即 ready', () => {
|
||||
@@ -65,9 +65,9 @@ describe('assembleInput —— 缺必填即起单失败入死信', () => {
|
||||
expect(result).toEqual({ kind: 'ready', data: { customerCode: 'C001', channel: 'mail' } });
|
||||
});
|
||||
|
||||
it('缺必填即 missing,并指出缺谁', () => {
|
||||
it('缺必填即 partial,并指出缺谁', () => {
|
||||
const result = assembleInput(eventTrigger(), scopeOf({ ...scope, keys: {} }), ['customerCode'], evalExpr);
|
||||
expect(result).toEqual({ kind: 'missing', fields: ['customerCode'] });
|
||||
expect(result).toMatchObject({ kind: 'partial', fields: ['customerCode'] });
|
||||
});
|
||||
|
||||
it('常量映射不依赖事件', () => {
|
||||
|
||||
@@ -41,8 +41,11 @@ export function matchesEvent(config: TriggerConfig, scope: EventScope, evaluate:
|
||||
|
||||
export type AssemblyResult =
|
||||
| { readonly kind: 'ready'; readonly data: Record<string, unknown> }
|
||||
/** 缺必填字段则**起单失败、入死信** */
|
||||
| { readonly kind: 'missing'; readonly fields: readonly string[] };
|
||||
/**
|
||||
* 缺必填 —— **不再是终局**(2026-07-30 宽进):装到多少带多少,缺的字段列出来。
|
||||
* 收敛点在活动:受理归一、信息不全派补全单;门口只装配,不裁决。
|
||||
*/
|
||||
| { readonly kind: 'partial'; readonly data: Record<string, unknown>; readonly fields: readonly string[] };
|
||||
|
||||
export function assembleInput(
|
||||
config: TriggerConfig,
|
||||
@@ -75,7 +78,9 @@ export function assembleInput(
|
||||
}
|
||||
}
|
||||
const missing = required.filter((field) => data[field] === undefined || data[field] === null);
|
||||
return missing.length > 0 ? { kind: 'missing', fields: missing } : { kind: 'ready', data };
|
||||
// 表达式没算出来的键别以 undefined 占位 —— 下游按「键在不在」判缺不缺
|
||||
for (const key of Object.keys(data)) if (data[key] === undefined || data[key] === null) delete data[key];
|
||||
return missing.length > 0 ? { kind: 'partial', data, fields: missing } : { kind: 'ready', data };
|
||||
}
|
||||
|
||||
function pick(scope: Readonly<Record<string, unknown>>, path: string): unknown {
|
||||
|
||||
@@ -35,14 +35,11 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService {
|
||||
): Promise<FireOutcome> {
|
||||
const required = await deps.process.requiredInputs(config.processPackageId);
|
||||
const assembled = assembleInput(config, scope, required, deps.sfeel.expression);
|
||||
if (assembled.kind === 'missing') {
|
||||
await deps.deadLetters.report({
|
||||
triggerId: config.id,
|
||||
reason: `起单失败:缺必填字段 ${assembled.fields.join('、')}`,
|
||||
detail: { scope },
|
||||
});
|
||||
return { triggerId: config.id, skipped: 'input-missing', missing: assembled.fields };
|
||||
}
|
||||
/*
|
||||
* 宽进(2026-07-30,用户定):**缺必填不再入死信** —— 装到多少带多少照样起单。
|
||||
* 收敛点在活动:进活动后受理归一,信息不全由任务侧派补全单收齐。
|
||||
* 门口只负责把流起起来;把单卡死在门外,人连「差什么」都要翻死信才知道。
|
||||
*/
|
||||
/*
|
||||
* 触发确认链路(OS 用户口述五步的第 4/5 步,2026-07-30):
|
||||
* 勾了「需人工确认」,命中**不等于起单** —— 装配好的入参存成待确认单,
|
||||
|
||||
Reference in New Issue
Block a user