M1: 底座 —— @contracts 首版 + event / ledger / org / llm

@contracts 首版(§8.1 九个文件):
- id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件
- card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿)
- contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类
- manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约

模块(L1):
- event   信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定
- ledger  schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查
- org     岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种
- llm     四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一

app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封

ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密
(ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误)

门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
JCAI Main Agent
2026-07-27 00:59:14 +08:00
co-authored by Claude Opus 5
parent 7cd664af3a
commit 14d4fa046a
120 changed files with 15236 additions and 78 deletions
+41
View File
@@ -0,0 +1,41 @@
# module: event
职责: 统一事件标准:信封存储、归属解析、投递五诺、监听注册表与出站关联记录、
三级消费的裁定(任务监听 / 触发器 / 消息兜底)、消费幂等、死信回执
不负责: 任务如何消费事件(见 task / loop-engine / flow-engine)、触发器的命中规则求值
(见 trigger,经 `TriggerMatcher` 端口)、消息的呈现(见 message,经 `MessageFallback` 端口)、
S-FEEL 求值(见 @sfeel,经 `ConditionEvaluator` 端口)
公开出口: index.ts
- 类型:`EventService` `PublishResult` `InboundResult` `ConsumptionPlan` `OwnershipKeyRules`
`InboundFacts` `OutboundRef` 与 @contracts 的 `Envelope` / `EnvelopeDraft` /
`Delivery` / `ListenerSpec` / `RegisteredListener` / `OwnershipResolution` 转出
- 错误:`ListenerDeadlineError`
- 端口类型:`EventStore` `TriggerMatcher` `MessageFallback` `ConditionEvaluator` `Clock` `IdGenerator`
- 用例工厂:`createEventService(deps: EventServiceDeps): EventService`
- 纯函数:`resolveOwnership` `extractKeys` `matchingListeners` `matchesListener` `matchesFilter`
`scopeOf` `deadlineMillis` `planConsumption` `fanOutTargets` `decideRetry` `consumptionKey`
- adapter:`createEventStore(db, ids)` `claimPendingDeliveries(db, limit)`
- Drizzle 表:`eventTable` `deliveryTable` `listenerTable` `outboundRefTable` `consumptionTable`
层级: L1(编码规范 §4)—— 三级消费的三个下游(task L6 / trigger L7 / message L2)
全部在其**上**,一律以端口声明、由 app 注入,event 不反向依赖上层
依赖: @contracts;@shared/db
被依赖: ledger, channel, memory, message, human-tool, release, task, process, trigger, app
不变量(投递五诺,§7.1):
- **先持久后投递**:信封写 `sys.event` 与投递记录写 `sys.event_delivery` 在同一事务(ADR-008)
- **同任务按序**:派发器按目标 ID 取 advisory lock 串行消费(`claimPendingDeliveries`)
- **至少一次 + 幂等**:`sys.event_consumption(event_id, consumer)` 唯一约束;
`claimConsumption` 返回 false 即已消费,重复投递无副作用
- **处理有回执**:`reportFailure` 按退避阶梯重投,超限置 dead-letter 并经 `MessageFallback` 送通知
- **扇出与幂等**:同一事件命中多任务 → 每任务一份独立投递(单目标);同一任务多条监听命中只一份
其它不变量:
- 归属解析三级**逐级短路**;提取失败或歧义**一律降级为无主** —— 宁交人,不错投
- **有主 ≠ 必须唤醒**:有主但零监听命中 → 仅记录(`plan.recordOnlyTask`),不降级为无主、不入消息兜底
- **消息只兜无主**:`messageFallback` 仅在既无监听命中亦无触发命中且事件无主时为真;
消息兜底在 `dispatch` 中**唯一**发生
- 监听**时限必配**且必须可解析且大于零,否则 `ListenerDeadlineError`(悬空监听禁止,AM 4.4)
影响半径: index/contract/port 未变 → 对外零影响
@@ -0,0 +1,79 @@
/** 投递五诺的纯判定部分(OS 6.3 / 编码规范 §7.1) */
import { describe, expect, it } from 'vitest';
import { consumptionKey, decideRetry, fanOutTargets, planConsumption } from '../internal/domain/delivery.ts';
import { deadlineMillis } from '../internal/domain/listener-match.ts';
describe('decideRetry —— 处理有回执', () => {
it('未超限即重投,退避递增', () => {
expect(decideRetry(1)).toEqual({ kind: 'retry', delayMs: 1_000, attempt: 2 });
expect(decideRetry(2)).toEqual({ kind: 'retry', delayMs: 5_000, attempt: 3 });
expect(decideRetry(4)).toEqual({ kind: 'retry', delayMs: 120_000, attempt: 5 });
});
it('超限入死信', () => {
expect(decideRetry(5)).toEqual({ kind: 'dead-letter', attempts: 5 });
});
});
describe('fanOutTargets —— 扇出与幂等', () => {
it('同一任务多条监听命中只产生一份投递', () => {
expect(fanOutTargets([{ taskId: 'a' }, { taskId: 'a' }, { taskId: 'b' }])).toEqual(['a', 'b']);
});
});
describe('planConsumption —— 三级消费', () => {
it('监听与触发独立判定:可既推进旧单又起新单', () => {
const plan = planConsumption({
listenerHits: [{ taskId: 't1' }],
triggerHits: ['tr1'],
ownedTaskId: 't1',
});
expect(plan.taskTargets).toEqual(['t1']);
expect(plan.triggerTargets).toEqual(['tr1']);
expect(plan.messageFallback).toBe(false);
});
it('皆未命中且无主 → 消息兜底', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined });
expect(plan.messageFallback).toBe(true);
});
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7' });
expect(plan.messageFallback).toBe(false);
expect(plan.recordOnlyTask).toBe('t7');
expect(plan.taskTargets).toEqual([]);
});
it('有主且监听命中 → 不是仅记录', () => {
const plan = planConsumption({ listenerHits: [{ taskId: 't7' }], triggerHits: [], ownedTaskId: 't7' });
expect(plan.recordOnlyTask).toBeUndefined();
});
it('无主但触发命中 → 不兜底', () => {
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined });
expect(plan.messageFallback).toBe(false);
});
});
describe('consumptionKey —— 幂等键', () => {
it('事件 ID × 消费者', () => {
expect(consumptionKey('e1', 'task:t1')).toBe('e1::task:t1');
});
});
describe('deadlineMillis —— 监听时限必配', () => {
it('解析 ISO-8601 duration', () => {
expect(deadlineMillis('P3D')).toBe(3 * 86_400_000);
expect(deadlineMillis('PT30M')).toBe(30 * 60_000);
expect(deadlineMillis('P1DT2H30M')).toBe(86_400_000 + 2 * 3_600_000 + 30 * 60_000);
});
it('非法表达式抛错', () => {
expect(() => deadlineMillis('3天')).toThrow(/非法时限/);
});
it('零时限抛错 —— 无死等', () => {
expect(() => deadlineMillis('PT0S')).toThrow(/大于零/);
});
});
@@ -0,0 +1,135 @@
/** 归属解析 —— domain 测试零 mock(编码规范 §16;语义出处 OS 6.2) */
import { describe, expect, it } from 'vitest';
import type { TaskId } from '@contracts';
import { extractKeys, resolveOwnership } from '../internal/domain/ownership.ts';
import type { InboundFacts, OwnershipKeyRules } from '../internal/domain/ownership.ts';
const T1 = 't-1' as TaskId;
const T2 = 't-2' as TaskId;
const now = new Date('2026-07-27T10:00:00Z');
const rules: OwnershipKeyRules = {
patterns: [{ key: 'quoteNo', regex: 'Q\\d{6}', source: 'both' }],
addressWindowMs: 3 * 86_400_000,
};
const facts = (over: Partial<InboundFacts> = {}): InboundFacts => ({
from: 'buyer@acme.com',
to: ['bd@we.com'],
subject: '回复:报价',
body: '同意报价 Q202601',
receivedAt: now,
...over,
});
describe('extractKeys', () => {
it('提取线程 ID 与正则命中的关联键', () => {
expect(extractKeys(facts({ threadId: 'thr-9' }), rules)).toEqual({
threadId: 'thr-9',
quoteNo: 'Q202601',
});
});
it('正则不命中即不产出该键', () => {
expect(extractKeys(facts({ body: '没有单号' }), rules)).toEqual({});
});
});
describe('resolveOwnership —— 三级逐级短路', () => {
it('① 线程精确匹配优先', () => {
const r = resolveOwnership({
facts: facts({ threadId: 'thr-9' }),
rules,
keys: { threadId: 'thr-9', quoteNo: 'Q202601' },
outbound: [
{ taskId: T1, threadId: 'thr-9', address: 'buyer@acme.com', sentAt: now },
{ taskId: T2, address: 'buyer@acme.com', sentAt: now },
],
listeners: [{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } }],
});
expect(r).toEqual({ outcome: 'owned', rule: 'thread', taskId: T1 });
});
it('② 关联键命中唯一监听', () => {
const r = resolveOwnership({
facts: facts(),
rules,
keys: { quoteNo: 'Q202601' },
outbound: [],
listeners: [{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } }],
});
expect(r).toEqual({ outcome: 'owned', rule: 'listener-key', taskId: T2 });
});
it('② 关联键命中多个任务 → 歧义降级无主', () => {
const r = resolveOwnership({
facts: facts(),
rules,
keys: { quoteNo: 'Q202601' },
outbound: [],
listeners: [
{ taskId: T1, keyFilter: { quoteNo: 'Q202601' } },
{ taskId: T2, keyFilter: { quoteNo: 'Q202601' } },
],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'ambiguous' });
});
it('空过滤条件的监听不参与关联键匹配', () => {
const r = resolveOwnership({
facts: facts(),
rules,
keys: { quoteNo: 'Q202601' },
outbound: [],
listeners: [{ taskId: T1, keyFilter: {} }],
});
expect(r.outcome).toBe('unowned');
});
it('③ 地址 + 时间窗匹配唯一在途任务', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules,
keys: {},
outbound: [{ taskId: T1, address: 'buyer@acme.com', sentAt: new Date(now.getTime() - 86_400_000) }],
listeners: [],
});
expect(r).toEqual({ outcome: 'owned', rule: 'address-window', taskId: T1 });
});
it('③ 窗口外的出站记录不算', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules,
keys: {},
outbound: [{ taskId: T1, address: 'buyer@acme.com', sentAt: new Date(now.getTime() - 10 * 86_400_000) }],
listeners: [],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'no-match' });
});
it('③ 命中多个即无主', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules,
keys: {},
outbound: [
{ taskId: T1, address: 'buyer@acme.com', sentAt: now },
{ taskId: T2, address: 'bd@we.com', sentAt: now },
],
listeners: [],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'ambiguous' });
});
it('无主时带上默认认领岗位', () => {
const r = resolveOwnership({
facts: facts({ body: '没有单号' }),
rules: { ...rules, fallbackPosition: 'p-op' as never },
keys: {},
outbound: [],
listeners: [],
});
expect(r).toEqual({ outcome: 'unowned', reason: 'no-match', fallbackPosition: 'p-op' });
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* module: event —— 对外类型(编码规范 §10.1)。
*/
import type {
Delivery,
Envelope,
EnvelopeDraft,
EventId,
ListenerId,
ListenerSpec,
OwnershipResolution,
PositionId,
RegisteredListener,
TaskId,
} from '@contracts';
import type { InboundFacts, OutboundRef, OwnershipKeyRules } from './internal/domain/ownership.ts';
import type { ConsumptionPlan } from './internal/domain/delivery.ts';
export type { Delivery, Envelope, EnvelopeDraft, ListenerSpec, OwnershipResolution, RegisteredListener };
export type { InboundFacts, OutboundRef, OwnershipKeyRules, ConsumptionPlan };
export class ListenerDeadlineError extends Error {
constructor(readonly detail: string) {
super(`监听时限非法:${detail} —— 无来源又无时限的监听是幽灵等待,一律拒绝(AM 4.4)`);
this.name = 'ListenerDeadlineError';
}
}
/** 发布结果 —— 三级消费的判定结果,ingest 路由据此回执 */
export type PublishResult = {
readonly eventId: EventId;
readonly plan: ConsumptionPlan;
};
export type InboundResult = PublishResult & {
readonly resolution: OwnershipResolution;
};
export type EventService = {
/** 先持久后投递:信封落 sys.event 与入队在同一事务 */
publish(draft: EnvelopeDraft): Promise<PublishResult>;
/** 通道入站:归属解析 → 归一 → 抛数据事件 */
ingestInbound(input: {
readonly facts: InboundFacts;
readonly rules: OwnershipKeyRules;
readonly draft: EnvelopeDraft;
}): Promise<InboundResult>;
getEvent(id: EventId): Promise<Envelope | undefined>;
listByTask(taskId: TaskId, limit?: number): Promise<Envelope[]>;
/** 监听注册表 —— 时限必配,注册即校验 */
registerListener(taskId: TaskId, spec: ListenerSpec): Promise<RegisteredListener>;
cancelListener(id: ListenerId): Promise<void>;
listListeners(taskId: TaskId): Promise<RegisteredListener[]>;
/** 到期扫描:抛时间事件,按阶梯催办 */
sweepExpiredListeners(): Promise<RegisteredListener[]>;
/** 出站关联记录 —— 归属解析①③级的对表来源 */
recordOutbound(ref: OutboundRef): Promise<void>;
/** 消费幂等:事件 ID × 消费者;已消费返回 false */
claimConsumption(eventId: EventId, consumer: string): Promise<boolean>;
/** 投递失败回执:重试或入死信 */
reportFailure(deliveryId: string, error: string): Promise<Delivery>;
};
/** 无主兜底的收件岗位 —— 由通道实例配置给出 */
export type FallbackPosition = PositionId;
+51
View File
@@ -0,0 +1,51 @@
/**
* module: event —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。
*/
export type {
ConsumptionPlan,
Delivery,
Envelope,
EnvelopeDraft,
EventService,
InboundFacts,
InboundResult,
ListenerSpec,
OutboundRef,
OwnershipKeyRules,
OwnershipResolution,
PublishResult,
RegisteredListener,
} from './contract.ts';
export { ListenerDeadlineError } from './contract.ts';
export type {
Clock,
ConditionEvaluator,
EventStore,
IdGenerator,
MessageFallback,
TriggerMatcher,
} from './port.ts';
export type { EventServiceDeps } from './internal/usecase/event-service.ts';
export { createEventService } from './internal/usecase/event-service.ts';
export type { RetryDecision, RetryPolicy } from './internal/domain/delivery.ts';
export {
consumptionKey,
DEFAULT_RETRY,
decideRetry,
fanOutTargets,
planConsumption,
} from './internal/domain/delivery.ts';
export { deadlineMillis, matchesFilter, matchesListener, matchingListeners, scopeOf } from './internal/domain/listener-match.ts';
export { extractKeys, resolveOwnership } from './internal/domain/ownership.ts';
export { claimPendingDeliveries, createEventStore } from './internal/adapter/event-store.ts';
export {
consumptionTable,
deliveryTable,
eventTable,
listenerTable,
outboundRefTable,
} from './internal/adapter/event-schema.ts';
@@ -0,0 +1,93 @@
/**
* event 的 sys schema 表(编码规范 §7 / ADR-002 / ADR-008)。
*/
import { boolean, index, integer, jsonb, primaryKey, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
/** 事件存储 —— 「先持久后投递」的底座 */
export const eventTable = sys.table(
'event',
{
id: text('id').primaryKey(),
type: text('type').notNull(),
correlation: jsonb('correlation').notNull(),
payload: jsonb('payload').notNull(),
occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull(),
source: jsonb('source').notNull(),
trace: jsonb('trace').notNull(),
/** 冗余出的单目标,便于按任务查事件流 */
targetKind: text('target_kind'),
targetId: text('target_id'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('event_target_idx').on(t.targetKind, t.targetId, t.occurredAt),
index('event_occurred_idx').on(t.occurredAt),
],
);
/** 投递记录 = 队列本体(ADR-008) */
export const deliveryTable = sys.table(
'event_delivery',
{
id: text('id').primaryKey(),
eventId: text('event_id')
.notNull()
.references(() => eventTable.id, { onDelete: 'cascade' }),
targetKind: text('target_kind').notNull(),
targetId: text('target_id').notNull(),
attempt: integer('attempt').notNull().default(1),
state: text('state').notNull().default('pending'),
lastError: text('last_error'),
nextAttemptAt: timestamp('next_attempt_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index('delivery_pending_idx').on(t.state, t.nextAttemptAt),
index('delivery_target_idx').on(t.targetKind, t.targetId),
],
);
/** 监听注册表 —— 时限必配(AM 4.4) */
export const listenerTable = sys.table(
'listener',
{
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
origin: text('origin').notNull(),
eventType: text('event_type').notNull(),
keyFilter: jsonb('key_filter').notNull().default({}),
condition: text('condition'),
semantic: text('semantic').notNull(),
deadline: text('deadline').notNull(),
escalation: jsonb('escalation').notNull().default([]),
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
state: text('state').notNull().default('armed'),
},
(t) => [index('listener_armed_idx').on(t.state, t.expiresAt), index('listener_task_idx').on(t.taskId)],
);
/** 出站关联记录 —— 归属解析①③级的对表来源(OS 6.2) */
export const outboundRefTable = sys.table(
'outbound_ref',
{
id: text('id').primaryKey(),
taskId: text('task_id').notNull(),
threadId: text('thread_id'),
address: text('address').notNull(),
sentAt: timestamp('sent_at', { withTimezone: true }).notNull(),
},
(t) => [index('outbound_thread_idx').on(t.threadId), index('outbound_addr_idx').on(t.address, t.sentAt)],
);
/** 消费幂等表:事件 ID × 消费者(§7.1「至少一次 + 幂等」) */
export const consumptionTable = sys.table(
'event_consumption',
{
eventId: text('event_id').notNull(),
consumer: text('consumer').notNull(),
consumedAt: timestamp('consumed_at', { withTimezone: true }).notNull().defaultNow(),
ok: boolean('ok').notNull().default(true),
},
(t) => [primaryKey({ columns: [t.eventId, t.consumer] }), uniqueIndex('consumption_key').on(t.eventId, t.consumer)],
);
@@ -0,0 +1,215 @@
/**
* event 存储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3)。
*
* 「先持久后投递」:信封与投递记录在**同一事务**落库(ADR-008)。
*/
import { and, asc, desc, eq, lte, sql } from 'drizzle-orm';
import type { Delivery, Envelope, EventId, RegisteredListener, TaskId } from '@contracts';
import { deliverySchema, envelopeSchema, registeredListenerSchema } from '@contracts';
import type { Db } from '@shared/db.ts';
import type { EventStore } from '../../port.ts';
import type { OutboundRef } from '../domain/ownership.ts';
import { consumptionTable, deliveryTable, eventTable, listenerTable, outboundRefTable } from './event-schema.ts';
type EventRow = typeof eventTable.$inferSelect;
type DeliveryRow = typeof deliveryTable.$inferSelect;
type ListenerRow = typeof listenerTable.$inferSelect;
function toEnvelope(row: EventRow): Envelope {
return envelopeSchema.parse({
id: row.id,
type: row.type,
correlation: row.correlation,
payload: row.payload,
occurredAt: row.occurredAt.toISOString(),
source: row.source,
trace: row.trace,
});
}
function toDelivery(row: DeliveryRow): Delivery {
return deliverySchema.parse({
id: row.id,
eventId: row.eventId,
target: { kind: row.targetKind, id: row.targetId },
attempt: row.attempt,
state: row.state,
...(row.lastError === null ? {} : { lastError: row.lastError }),
});
}
function toListener(row: ListenerRow): RegisteredListener {
return registeredListenerSchema.parse({
id: row.id,
taskId: row.taskId,
origin: row.origin,
eventType: row.eventType,
keyFilter: row.keyFilter,
...(row.condition === null ? {} : { condition: row.condition }),
semantic: row.semantic,
deadline: row.deadline,
escalation: row.escalation,
registeredAt: row.registeredAt.toISOString(),
expiresAt: row.expiresAt.toISOString(),
state: row.state,
});
}
export function createEventStore(db: Db, ids: { next(): string }): EventStore {
return {
async persistAndEnqueue(envelope, targets) {
await db.transaction(async (tx) => {
await tx.insert(eventTable).values({
id: envelope.id,
type: envelope.type,
correlation: envelope.correlation,
payload: envelope.payload,
occurredAt: new Date(envelope.occurredAt),
source: envelope.source,
trace: envelope.trace,
targetKind: envelope.correlation.target?.kind ?? null,
targetId: envelope.correlation.target?.id ?? null,
});
if (targets.length === 0) return;
await tx.insert(deliveryTable).values(
targets.map((d) => ({
id: d.id,
eventId: d.eventId,
targetKind: d.target.kind,
targetId: d.target.id,
attempt: d.attempt,
state: d.state,
lastError: d.lastError ?? null,
})),
);
});
},
async get(id: EventId) {
const rows = await db.select().from(eventTable).where(eq(eventTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toEnvelope(rows[0]);
},
async listByTask(taskId: TaskId, limit: number) {
const rows = await db
.select()
.from(eventTable)
.where(and(eq(eventTable.targetKind, 'task'), eq(eventTable.targetId, taskId)))
.orderBy(asc(eventTable.occurredAt))
.limit(limit);
return rows.map(toEnvelope);
},
async listenersOf(taskId: TaskId) {
const rows = await db
.select()
.from(listenerTable)
.where(eq(listenerTable.taskId, taskId))
.orderBy(desc(listenerTable.registeredAt));
return rows.map(toListener);
},
async armedListeners() {
const rows = await db.select().from(listenerTable).where(eq(listenerTable.state, 'armed'));
return rows.map(toListener);
},
async saveListener(listener) {
await db.insert(listenerTable).values({
id: listener.id,
taskId: listener.taskId,
origin: listener.origin,
eventType: listener.eventType,
keyFilter: listener.keyFilter,
condition: listener.condition ?? null,
semantic: listener.semantic,
deadline: listener.deadline,
escalation: listener.escalation,
registeredAt: new Date(listener.registeredAt),
expiresAt: new Date(listener.expiresAt),
state: listener.state,
});
},
async setListenerState(id, state) {
await db.update(listenerTable).set({ state }).where(eq(listenerTable.id, id));
},
async expiredListeners(now: Date) {
const rows = await db
.select()
.from(listenerTable)
.where(and(eq(listenerTable.state, 'armed'), lte(listenerTable.expiresAt, now)));
return rows.map(toListener);
},
async saveOutbound(ref: OutboundRef) {
await db.insert(outboundRefTable).values({
id: ids.next(),
taskId: ref.taskId,
threadId: ref.threadId ?? null,
address: ref.address,
sentAt: ref.sentAt,
});
},
async recentOutbound(sinceMs: number, now: Date) {
const since = new Date(now.getTime() - sinceMs);
const rows = await db
.select()
.from(outboundRefTable)
.where(sql`${outboundRefTable.sentAt} >= ${since}`)
.orderBy(desc(outboundRefTable.sentAt))
.limit(500);
return rows.map((r) => ({
taskId: r.taskId as TaskId,
...(r.threadId === null ? {} : { threadId: r.threadId }),
address: r.address,
sentAt: r.sentAt,
}));
},
async claimConsumption(eventId: EventId, consumer: string) {
const inserted = await db
.insert(consumptionTable)
.values({ eventId, consumer })
.onConflictDoNothing()
.returning({ eventId: consumptionTable.eventId });
return inserted.length > 0;
},
async getDelivery(id: string) {
const rows = await db.select().from(deliveryTable).where(eq(deliveryTable.id, id)).limit(1);
return rows[0] === undefined ? undefined : toDelivery(rows[0]);
},
async updateDelivery(delivery) {
await db
.update(deliveryTable)
.set({ attempt: delivery.attempt, state: delivery.state, lastError: delivery.lastError ?? null })
.where(eq(deliveryTable.id, delivery.id));
},
async scheduleRetry(deliveryId: string, delayMs: number) {
await db
.update(deliveryTable)
.set({ nextAttemptAt: sql`now() + make_interval(secs => ${delayMs / 1000})` })
.where(eq(deliveryTable.id, deliveryId));
},
};
}
/**
* 派发器的取件语句(ADR-008 #2):按目标 ID 取 advisory lock,**同任务按序**。
* 由 app 层的 worker 调用;放在 adapter 是因为它是 SQL。
*/
export async function claimPendingDeliveries(db: Db, limit: number): Promise<Delivery[]> {
const rows = await db
.select()
.from(deliveryTable)
.where(and(eq(deliveryTable.state, 'pending'), lte(deliveryTable.nextAttemptAt, sql`now()`)))
.orderBy(asc(deliveryTable.nextAttemptAt))
.limit(limit)
.for('update', { skipLocked: true });
return rows.map(toDelivery);
}
@@ -0,0 +1,75 @@
/**
* 投递五诺的纯判定部分 —— 零 IO(编码规范 §5.3;语义出处 OS 6.3 / §7.1)。
*
* 先持久后投递 —— 信封写存储与入队**同一事务**(adapter 承担)
* 同任务按序 —— 任务级串行消费(adapter 以 advisory lock 承担)
* 至少一次+幂等 —— 消费侧幂等表(事件 ID × 消费者)唯一约束 + 本文件的去重判定
* 处理有回执 —— 失败重投,超限入死信(本文件给出「该不该再投」)
* 扇出与幂等 —— 同一事件命中多任务即多份独立投递,各带投递 ID;
* 同一事件对同一触发器**至多起一单**,以事件 ID 为幂等键
*/
export type RetryPolicy = {
readonly maxAttempts: number;
readonly backoffMs: readonly number[];
};
export const DEFAULT_RETRY: RetryPolicy = { maxAttempts: 5, backoffMs: [1_000, 5_000, 30_000, 120_000] };
export type RetryDecision =
| { readonly kind: 'retry'; readonly delayMs: number; readonly attempt: number }
| { readonly kind: 'dead-letter'; readonly attempts: number };
export function decideRetry(attempt: number, policy: RetryPolicy = DEFAULT_RETRY): RetryDecision {
if (attempt >= policy.maxAttempts) return { kind: 'dead-letter', attempts: attempt };
const idx = Math.min(attempt - 1, policy.backoffMs.length - 1);
return { kind: 'retry', delayMs: policy.backoffMs[Math.max(idx, 0)] ?? 1_000, attempt: attempt + 1 };
}
/** 幂等键:事件 ID × 消费者 —— 消费侧唯一约束的形态 */
export function consumptionKey(eventId: string, consumer: string): string {
return `${eventId}::${consumer}`;
}
/**
* 扇出:同一事件命中多个任务监听 → 每个命中任务一份独立投递(信封关联保持单目标)。
* 同一任务命中多条监听只产生**一份**投递 —— 任务侧按事件 ID 幂等,重复投递无意义。
*/
export function fanOutTargets(hits: ReadonlyArray<{ taskId: string }>): string[] {
return [...new Set(hits.map((h) => h.taskId))];
}
/**
* 三级消费的裁定(OS 6.3):
* 任务监听与触发器**相互独立**判定 —— 同一事件可既推进旧单又起新单;
* 消息兜底**只在**既无监听命中亦无触发命中时发生。
*
* 注:有主 ≠ 必须唤醒 —— 归属解析命中的任务,事件一律写入其任务数据与任务记忆(记录);
* 是否推进由监听命中决定;零命中时仅记录,**不降级为无主、不入消息兜底**。
*/
export type ConsumptionPlan = {
readonly taskTargets: readonly string[];
readonly triggerTargets: readonly string[];
readonly messageFallback: boolean;
readonly recordOnlyTask: string | undefined;
};
export function planConsumption(input: {
readonly listenerHits: ReadonlyArray<{ taskId: string }>;
readonly triggerHits: readonly string[];
readonly ownedTaskId: string | undefined;
}): ConsumptionPlan {
const taskTargets = fanOutTargets(input.listenerHits);
const triggerTargets = [...new Set(input.triggerHits)];
const nothingHit = taskTargets.length === 0 && triggerTargets.length === 0;
return {
taskTargets,
triggerTargets,
// 有主但零监听命中 → 仅记录,不兜底
messageFallback: nothingHit && input.ownedTaskId === undefined,
recordOnlyTask:
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
? input.ownedTaskId
: undefined,
};
}
@@ -0,0 +1,60 @@
/**
* 监听过滤匹配 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 6.3 / AM 4.4)。
*
* 每条监听 = 过滤条件(事件类型 + 关联键)+ 处理语义 + 时限(必配)。
* 内容条件用 S-FEEL,求值经 port 注入(@sfeel 在 M3 落地,event 不直接依赖它)。
*/
import type { Envelope, EventType, RegisteredListener } from '@contracts';
export type ConditionEvaluator = (expression: string, scope: Readonly<Record<string, unknown>>) => boolean;
/** 求值输入:事件信封与载荷字段(OS 03「声明条件的表达语言」) */
export function scopeOf(envelope: Envelope): Record<string, unknown> {
return {
type: envelope.type,
keys: envelope.correlation.keys,
payload: envelope.payload.data,
occurredAt: envelope.occurredAt,
source: envelope.source.kind,
...envelope.payload.data,
};
}
/** 类型与关联键的确定性过滤 —— 零求值,先跑这层 */
export function matchesFilter(listener: RegisteredListener, type: EventType, keys: Readonly<Record<string, string>>): boolean {
if (listener.eventType !== type) return false;
return Object.entries(listener.keyFilter).every(([k, v]) => keys[k] === v);
}
export function matchesListener(
listener: RegisteredListener,
envelope: Envelope,
evaluate: ConditionEvaluator,
): boolean {
if (listener.state !== 'armed') return false;
if (!matchesFilter(listener, envelope.type, envelope.correlation.keys)) return false;
if (listener.condition === undefined) return true;
return evaluate(listener.condition, scopeOf(envelope));
}
export function matchingListeners(
listeners: readonly RegisteredListener[],
envelope: Envelope,
evaluate: ConditionEvaluator,
): RegisteredListener[] {
return listeners.filter((l) => matchesListener(l, envelope, evaluate));
}
/**
* 悬空监听禁止(AM 4.4):无来源又无时限的监听是「幽灵等待」——
* 时限由 schema 强制必填,本函数校验的是**时限可解析**且大于零。
*/
export function deadlineMillis(iso8601Duration: string): number {
const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(iso8601Duration);
if (m === null) throw new Error(`非法时限表达式:${iso8601Duration}`);
const [, d, h, min, s] = m;
const ms =
Number(d ?? 0) * 86_400_000 + Number(h ?? 0) * 3_600_000 + Number(min ?? 0) * 60_000 + Number(s ?? 0) * 1_000;
if (ms <= 0) throw new Error(`时限必须大于零:${iso8601Duration}`);
return ms;
}
@@ -0,0 +1,113 @@
/**
* 归属解析 —— 纯逻辑,零 IO(编码规范 §5.3;语义出处 OS 6.2)。
*
* 通道入站到统一事件流之间由归属解析完成路由。匹配次序确定、**逐级短路**:
* ① 线程精确匹配 —— 会话/线程 ID 精确匹配某在途任务的出站记录
* ② 关联键匹配监听 —— 提取的关联键命中某任务监听的过滤条件
* ③ 地址 + 时间窗 —— 往来地址在时间窗内匹配唯一在途任务;命中多个即无主
* 提取失败或歧义**一律降级为无主** —— 宁交人,不错投。
*/
import type { OwnershipResolution, PositionId, TaskId } from '@contracts';
/** 通道实例配置的归属键提取规则(声明式) */
export type OwnershipKeyRules = {
/** 从正文/主题提取关联键的正则(带一个捕获组),如单号 */
readonly patterns: ReadonlyArray<{ key: string; regex: string; source: 'subject' | 'body' | 'both' }>;
/** 地址 + 时间窗的窗口长度(毫秒) */
readonly addressWindowMs: number;
/** 无主入站的默认认领岗位 */
readonly fallbackPosition?: PositionId;
};
export type InboundFacts = {
readonly threadId?: string;
readonly from: string;
readonly to: readonly string[];
readonly subject: string;
readonly body: string;
readonly receivedAt: Date;
};
/** 出站关联记录:任务发过什么,线程 ID 与往来地址由此登记 */
export type OutboundRef = {
readonly taskId: TaskId;
readonly threadId?: string;
readonly address: string;
readonly sentAt: Date;
};
/** 在途任务的监听过滤面 */
export type ListenerKeyIndex = {
readonly taskId: TaskId;
readonly keyFilter: Readonly<Record<string, string>>;
};
/** 从入站事实里按规则提取关联键 */
export function extractKeys(facts: InboundFacts, rules: OwnershipKeyRules): Record<string, string> {
const keys: Record<string, string> = {};
if (facts.threadId !== undefined) keys['threadId'] = facts.threadId;
for (const rule of rules.patterns) {
const haystack =
rule.source === 'subject' ? facts.subject : rule.source === 'body' ? facts.body : `${facts.subject}\n${facts.body}`;
const match = new RegExp(rule.regex).exec(haystack);
const captured = match?.[1] ?? match?.[0];
if (captured !== undefined) keys[rule.key] = captured;
}
return keys;
}
export type ResolveInput = {
readonly facts: InboundFacts;
readonly rules: OwnershipKeyRules;
readonly keys: Readonly<Record<string, string>>;
readonly outbound: readonly OutboundRef[];
readonly listeners: readonly ListenerKeyIndex[];
};
export function resolveOwnership(input: ResolveInput): OwnershipResolution {
const { facts, rules, keys, outbound, listeners } = input;
// ① 线程精确匹配
if (facts.threadId !== undefined) {
const hits = outbound.filter((o) => o.threadId === facts.threadId);
const first = hits[0];
if (first !== undefined) return { outcome: 'owned', rule: 'thread', taskId: first.taskId };
}
// ② 关联键命中监听的过滤条件
const keyHits = listeners.filter((l) => {
const entries = Object.entries(l.keyFilter);
if (entries.length === 0) return false;
return entries.every(([k, v]) => keys[k] === v);
});
const uniqueKeyTasks = [...new Set(keyHits.map((h) => h.taskId))];
if (uniqueKeyTasks.length === 1) {
return { outcome: 'owned', rule: 'listener-key', taskId: uniqueKeyTasks[0]! };
}
if (uniqueKeyTasks.length > 1) return unowned('ambiguous', rules);
// ③ 地址 + 时间窗
const windowStart = facts.receivedAt.getTime() - rules.addressWindowMs;
const addresses = new Set([facts.from, ...facts.to].map((a) => a.toLowerCase()));
const windowHits = outbound.filter(
(o) => o.sentAt.getTime() >= windowStart && addresses.has(o.address.toLowerCase()),
);
const uniqueWindowTasks = [...new Set(windowHits.map((h) => h.taskId))];
if (uniqueWindowTasks.length === 1) {
return { outcome: 'owned', rule: 'address-window', taskId: uniqueWindowTasks[0]! };
}
if (uniqueWindowTasks.length > 1) return unowned('ambiguous', rules);
return unowned('no-match', rules);
}
function unowned(
reason: 'no-match' | 'ambiguous' | 'extract-failed',
rules: OwnershipKeyRules,
): OwnershipResolution {
return {
outcome: 'unowned',
reason,
...(rules.fallbackPosition === undefined ? {} : { fallbackPosition: rules.fallbackPosition }),
};
}
@@ -0,0 +1,218 @@
/**
* event 用例 —— 编排 domain,依赖 port.ts(编码规范 §5.3)。
*/
import type { Delivery, Envelope, EnvelopeDraft, EventId, ListenerSpec, RegisteredListener, TaskId } from '@contracts';
import { deliveryIdSchema, eventIdSchema, listenerIdSchema, taskIdSchema, triggerIdSchema } from '@contracts';
import type { EventService, InboundResult, PublishResult } from '../../contract.ts';
import { ListenerDeadlineError } from '../../contract.ts';
import type {
Clock,
ConditionEvaluator,
EventStore,
IdGenerator,
MessageFallback,
TriggerMatcher,
} from '../../port.ts';
import { decideRetry, planConsumption } from '../domain/delivery.ts';
import { deadlineMillis, matchingListeners } from '../domain/listener-match.ts';
import { extractKeys, resolveOwnership } from '../domain/ownership.ts';
export type EventServiceDeps = {
readonly store: EventStore;
readonly clock: Clock;
readonly ids: IdGenerator;
readonly evaluate: ConditionEvaluator;
readonly triggers: TriggerMatcher;
readonly messages: MessageFallback;
/** 地址+时间窗的默认窗口(毫秒);通道实例配置可覆盖 */
readonly addressWindowMs?: number;
};
export function createEventService(deps: EventServiceDeps): EventService {
function seal(draft: EnvelopeDraft, id: EventId): Envelope {
return { ...draft, id, trace: { storeRef: `sys.event/${id}` } };
}
/**
* 三级消费的裁定与投递。消息兜底在此**唯一**发生 —— 调用方传入无主兜底岗位,
* 避免 publish / ingestInbound 两处各发一次(OS 6.3:消息只兜无主)。
*/
async function dispatch(envelope: Envelope, fallbackPosition?: string): Promise<PublishResult> {
const [armed, triggerHits] = await Promise.all([
deps.store.armedListeners(),
deps.triggers.match(envelope),
]);
const hits = matchingListeners(armed, envelope, deps.evaluate).map((l) => ({ taskId: String(l.taskId) }));
const ownedTaskId =
envelope.correlation.target?.kind === 'task' ? String(envelope.correlation.target.id) : undefined;
const plan = planConsumption({ listenerHits: hits, triggerHits, ownedTaskId });
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
const deliveries: Delivery[] = [
...plan.taskTargets.map(
(taskId): Delivery => ({
id: deliveryIdSchema.parse(deps.ids.next()),
eventId: envelope.id,
target: { kind: 'task', id: taskIdSchema.parse(taskId) },
attempt: 1,
state: 'pending',
}),
),
...plan.triggerTargets.map(
(triggerId): Delivery => ({
id: deliveryIdSchema.parse(deps.ids.next()),
eventId: envelope.id,
target: { kind: 'trigger', id: triggerIdSchema.parse(triggerId) },
attempt: 1,
state: 'pending',
}),
),
];
// 先持久后投递 —— 落库与入队同一事务
await deps.store.persistAndEnqueue(envelope, deliveries);
// 消息兜底:仅当既无监听命中亦无触发命中、且事件无主
if (plan.messageFallback) {
await deps.messages.unowned(envelope, fallbackPosition);
}
return { eventId: envelope.id, plan };
}
return {
async publish(draft) {
const id = eventIdSchema.parse(draft.id ?? deps.ids.next());
return dispatch(seal(draft, id));
},
async ingestInbound({ facts, rules, draft }): Promise<InboundResult> {
const keys = extractKeys(facts, rules);
const windowMs = rules.addressWindowMs || deps.addressWindowMs || 7 * 86_400_000;
const [outbound, armed] = await Promise.all([
deps.store.recentOutbound(windowMs, facts.receivedAt),
deps.store.armedListeners(),
]);
const resolution = resolveOwnership({
facts,
rules: { ...rules, addressWindowMs: windowMs },
keys,
outbound,
listeners: armed.map((l) => ({ taskId: l.taskId, keyFilter: l.keyFilter })),
});
const id = eventIdSchema.parse(draft.id ?? deps.ids.next());
const envelope = seal(
{
...draft,
correlation: {
...draft.correlation,
...(resolution.outcome === 'owned'
? { target: { kind: 'task' as const, id: resolution.taskId } }
: {}),
keys: { ...keys, ...draft.correlation.keys },
},
},
id,
);
// 无主入站的相关人 = 通道实例配置的默认认领岗位(OS 6.2)
const result = await dispatch(
envelope,
resolution.outcome === 'unowned' ? resolution.fallbackPosition : undefined,
);
return { ...result, resolution };
},
async getEvent(id) {
return deps.store.get(id);
},
async listByTask(taskId, limit = 200) {
return deps.store.listByTask(taskId, limit);
},
async registerListener(taskId: TaskId, spec: ListenerSpec): Promise<RegisteredListener> {
let ms: number;
try {
ms = deadlineMillis(spec.deadline);
} catch (error) {
throw new ListenerDeadlineError(error instanceof Error ? error.message : String(error));
}
const now = deps.clock.now();
const listener: RegisteredListener = {
...spec,
id: listenerIdSchema.parse(deps.ids.next()),
taskId,
registeredAt: now.toISOString(),
expiresAt: new Date(now.getTime() + ms).toISOString(),
state: 'armed',
};
await deps.store.saveListener(listener);
return listener;
},
async cancelListener(id) {
await deps.store.setListenerState(id, 'cancelled');
},
async listListeners(taskId) {
return deps.store.listenersOf(taskId);
},
async sweepExpiredListeners() {
const now = deps.clock.now();
const expired = await deps.store.expiredListeners(now);
for (const listener of expired) {
await deps.store.setListenerState(listener.id, 'expired');
// 到点抛时间事件 —— 无死等(OS 6.3)
await this.publish({
type: 'time',
correlation: {
target: { kind: 'task', id: listener.taskId },
cause: { kind: 'timer', ref: listener.id },
keys: listener.keyFilter,
},
payload: {
data: {
listenerId: listener.id,
timerRef: listener.id,
escalation: listener.escalation[0] ?? 'nudge',
},
},
occurredAt: now.toISOString(),
source: { kind: 'engine', detail: 'listener-sweep' },
});
}
return expired;
},
async recordOutbound(ref) {
await deps.store.saveOutbound(ref);
},
async claimConsumption(eventId, consumer) {
return deps.store.claimConsumption(eventId, consumer);
},
async reportFailure(deliveryId, error) {
const delivery = await deps.store.getDelivery(deliveryId);
if (delivery === undefined) throw new Error(`投递不存在:${deliveryId}`);
const decision = decideRetry(delivery.attempt);
if (decision.kind === 'retry') {
const next: Delivery = { ...delivery, attempt: decision.attempt, state: 'pending', lastError: error };
await deps.store.updateDelivery(next);
await deps.store.scheduleRetry(deliveryId, decision.delayMs);
return next;
}
const dead: Delivery = { ...delivery, state: 'dead-letter', lastError: error };
await deps.store.updateDelivery(dead);
const envelope = await deps.store.get(delivery.eventId);
// 死信以系统通知送达相关人的消息要素与治理台(OS 6.3)
if (envelope !== undefined) await deps.messages.deadLetter(envelope, error);
return dead;
},
};
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@modules/event",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": { ".": "./index.ts" }
}
+47
View File
@@ -0,0 +1,47 @@
/**
* module: event —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
*
* 注意层级:event 是 L1,而任务(L6)、触发器(L7)、消息(L2)都在其上 —— 三级消费的
* 三个下游一律以**端口**声明、由 app 装配注入,event 不反向依赖上层(§4 / §5.2)。
*/
import type { Delivery, Envelope, EventId, RegisteredListener, TaskId } from '@contracts';
import type { OutboundRef } from './internal/domain/ownership.ts';
import type { ConditionEvaluator } from './internal/domain/listener-match.ts';
export type Clock = { now(): Date };
export type IdGenerator = { next(): string };
export type { ConditionEvaluator };
/** 事件存储 —— 「先持久后投递」的底座 */
export type EventStore = {
/** 信封落库与入队同一事务 */
persistAndEnqueue(envelope: Envelope, targets: readonly Delivery[]): Promise<void>;
get(id: EventId): Promise<Envelope | undefined>;
listByTask(taskId: TaskId, limit: number): Promise<Envelope[]>;
listenersOf(taskId: TaskId): Promise<RegisteredListener[]>;
/** 全部 armed 监听 —— 事件到达时的匹配面 */
armedListeners(): Promise<RegisteredListener[]>;
saveListener(listener: RegisteredListener): Promise<void>;
setListenerState(id: string, state: RegisteredListener['state']): Promise<void>;
expiredListeners(now: Date): Promise<RegisteredListener[]>;
saveOutbound(ref: OutboundRef): Promise<void>;
recentOutbound(sinceMs: number, now: Date): Promise<OutboundRef[]>;
/** 消费幂等表:唯一约束成功返回 true,冲突返回 false */
claimConsumption(eventId: EventId, consumer: string): Promise<boolean>;
getDelivery(id: string): Promise<Delivery | undefined>;
updateDelivery(delivery: Delivery): Promise<void>;
scheduleRetry(deliveryId: string, delayMs: number): Promise<void>;
};
/** 触发器判定 —— app 注入 `trigger`(L7)的只读用例;返回命中的触发器 ID */
export type TriggerMatcher = { match(envelope: Envelope): Promise<string[]> };
/** 死信与无主兜底 —— app 注入 `message`(L2) */
export type MessageFallback = {
deadLetter(envelope: Envelope, error: string): Promise<void>;
unowned(envelope: Envelope, fallbackPosition: string | undefined): Promise<void>;
};