M2: 基座能力 —— memory / toolbox / channel / message
- memory 任务记忆四性质(只追加·信封盖戳·治理先行·归侧)、活动记忆四层与条目三件套、
任务日志两级投影(结构化零 LLM / 叙述性小结不入事实库)、度量八指标只读投影
- toolbox 注册两审(静态审 + 试运行审)、效应等级决定运行时待遇、契约演进只增不破坏、
白名单与纯工具私有作用域、ToolInvoker(两缝之二,live/record/replay)
- channel 通道实例配置、入站归一(解析 → 系统写 → 数据事件)、出站发送与关联记录登记
- message 通知 / 跨任务征答(就地作答 · 驳回重答只增不改)/ 死信 / 无主兜底
app:message 接上 event 的 MessageFallback;派发器按目标 ID 取 advisory lock 串行消费 +
消费幂等表;/api/ingest 模拟邮件通道(mock 只在外界)
已验证:无主 RFQ 邮件入站 → 归属解析 no-match → 事件落库 → 兜底落 OP 岗位的消息要素
门禁五道全绿;150 个单元测试 + 1 个 PG 集成测试
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
14d4fa046a
commit
f0444f1417
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* message 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
|
||||
*/
|
||||
import type { StaffId } from '@contracts';
|
||||
import type { MessageService, SendElicitation, SendNotice } from '../../contract.ts';
|
||||
import { MessageNotAnswerableError } from '../../contract.ts';
|
||||
import type { Clock, IdGenerator, MessageRepository, PositionResolver } from '../../port.ts';
|
||||
import type { MessageKind, SystemMessage } from '../domain/message-kind.ts';
|
||||
import { canAnswer } from '../domain/message-kind.ts';
|
||||
|
||||
export type MessageServiceDeps = {
|
||||
readonly repo: MessageRepository;
|
||||
readonly org: PositionResolver;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
};
|
||||
|
||||
export function createMessageService(deps: MessageServiceDeps): MessageService {
|
||||
function build(
|
||||
kind: MessageKind,
|
||||
staffId: StaffId,
|
||||
title: string,
|
||||
body: string,
|
||||
extra: Partial<SystemMessage> = {},
|
||||
): SystemMessage {
|
||||
return {
|
||||
id: deps.ids.next(),
|
||||
kind,
|
||||
staffId,
|
||||
title,
|
||||
body,
|
||||
at: deps.clock.now().toISOString(),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function fanOut(kind: MessageKind, staff: ReadonlyArray<{ id: StaffId }>, title: string, body: string, eventRef?: string) {
|
||||
for (const person of staff) {
|
||||
await deps.repo.insert(build(kind, person.id, title, body, eventRef === undefined ? {} : { eventRef }));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async inbox(staffId, options) {
|
||||
return deps.repo.list(staffId, options?.unreadOnly ?? false, options?.limit ?? 100);
|
||||
},
|
||||
|
||||
async markRead(messageId) {
|
||||
await deps.repo.markRead(messageId, deps.clock.now());
|
||||
},
|
||||
|
||||
async notify(input: SendNotice) {
|
||||
return deps.repo.insert(
|
||||
build('notice', input.staffId, input.title, input.body, input.eventRef === undefined ? {} : { eventRef: input.eventRef }),
|
||||
);
|
||||
},
|
||||
|
||||
async sendElicitation(input: SendElicitation) {
|
||||
return deps.repo.insert(
|
||||
build('elicitation', input.staffId, input.title, input.body, {
|
||||
card: { cardId: input.cardId, sourceTaskId: input.sourceTaskId, payload: input.payload },
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async answer(messageId, staffId, answer) {
|
||||
const message = await deps.repo.get(messageId);
|
||||
if (message === undefined || !canAnswer(message, staffId)) {
|
||||
throw new MessageNotAnswerableError(messageId);
|
||||
}
|
||||
await deps.repo.markAnswered(messageId, deps.clock.now(), answer);
|
||||
const updated = await deps.repo.get(messageId);
|
||||
if (updated === undefined) throw new MessageNotAnswerableError(messageId);
|
||||
return updated;
|
||||
},
|
||||
|
||||
/** 驳回重答:新卡实例(附驳回理由与原答引用),原卡闭合 —— 每次请求与回答都是独立消息 */
|
||||
async rejectAndReask(messageId, reason) {
|
||||
const original = await deps.repo.get(messageId);
|
||||
if (original === undefined || original.card === undefined) {
|
||||
throw new MessageNotAnswerableError(messageId);
|
||||
}
|
||||
const reask = build('elicitation', original.staffId as StaffId, original.title, original.body, {
|
||||
card: {
|
||||
cardId: deps.ids.next(),
|
||||
sourceTaskId: original.card.sourceTaskId,
|
||||
payload: original.card.payload,
|
||||
rejectedReason: reason,
|
||||
},
|
||||
});
|
||||
const created = await deps.repo.insert(reask);
|
||||
await deps.repo.supersede(messageId, created.id, reason);
|
||||
return created;
|
||||
},
|
||||
|
||||
/** 死信:送相关人的消息要素**与**治理台 · 任务管理(OS 6.3) */
|
||||
async deadLetter(envelope, error) {
|
||||
const governance = await deps.org.governanceStaff();
|
||||
await fanOut(
|
||||
'dead-letter',
|
||||
governance,
|
||||
`事件投递失败入死信:${envelope.type}`,
|
||||
`事件 ${envelope.id} 重试超限。原因:${error}`,
|
||||
envelope.trace.storeRef,
|
||||
);
|
||||
},
|
||||
|
||||
/** 无主兜底:相关人 = 通道实例配置的默认认领岗位;未配置则落治理台 */
|
||||
async unowned(envelope, fallbackPosition) {
|
||||
const recipients =
|
||||
fallbackPosition === undefined
|
||||
? await deps.org.governanceStaff()
|
||||
: await deps.org.staffOfPosition(fallbackPosition);
|
||||
const targets = recipients.length > 0 ? recipients : await deps.org.governanceStaff();
|
||||
const subject = typeof envelope.payload.data['subject'] === 'string' ? envelope.payload.data['subject'] : '(无主题)';
|
||||
await fanOut(
|
||||
'unowned',
|
||||
targets,
|
||||
`无主入站待认领:${subject}`,
|
||||
`事件 ${envelope.id} 未命中任何任务监听与触发器。原件:${envelope.payload.originRef ?? '无'}`,
|
||||
envelope.trace.storeRef,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user