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
@@ -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;
},
};
}