forked from ChaiHao/BPOS-MVP
- 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
126 lines
4.5 KiB
TypeScript
126 lines
4.5 KiB
TypeScript
/**
|
|
* 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,
|
|
);
|
|
},
|
|
};
|
|
}
|